130 Commits

Author SHA1 Message Date
e3ab4aba8b build: add a make cover target for per-function coverage (closes #17)
make cover writes build/coverage.out and prints the per-function report;
make cover-html renders the same profile to build/coverage.html. The
per-package percentage make test prints cannot say which function is
untested.

Both write files, so neither is in check, and neither may be added to it:
check must not modify the working tree. Their output lands under the
already-ignored build/.

Verified: make cover printed per-function lines and a 62.6% total,
make cover-html wrote build/coverage.html, and git status stayed clean.
GOFLAGS=-count=1 make check green in 24s with the lint layer executing
(12.0s, "0 issues.", not CACHED) and the suite running for real (cmd/rogue
1.037s, game 3.410s); check is still fmt-check lint test and git status is
clean afterwards.
2026-08-10 13:48:58 +00:00
60442ce103 build: add a make build target, drop README's raw go build (closes #19)
The executable builds to build/rogue. All generated artifacts go under
build/, which .gitignore covers as a whole; a target writing outside it
can commit its output.

build is in neither check nor test: check stays fmt-check lint test and
still writes nothing into the working tree.

README's "Building and running" block and the run examples use
./build/rogue, and no raw go invocation is left in the file.

Verified: make build writes build/rogue and git status stays clean;
GOFLAGS=-count=1 make check green in 34s with the lint layer executing
(21.9s, "0 issues.", not CACHED) and the suite running for real (cmd/rogue
1.027s, game 3.450s), git status clean afterwards.
2026-08-10 13:48:58 +00:00
6f997b8d5c docs: trim the lint-gate comments to the traps (closes #44)
Comments and documentation only; the docker build invocation and its three
flags are byte-identical and .dockerignore's effective rules are unchanged.

script/lint and Dockerfile.lint stated how the shape was derived — why two
stages, why `golangci-lint config verify` was omitted, what earlier drafts
of the comments claimed. That is in the history. What survives is the three
traps, each of which yields a green run over an unlinted or partly linted
tree: --target and --no-cache-filter must both stay with $stage matching the
stage name in Dockerfile.lint; --target checks that the stage exists, not
that it runs golangci-lint, and halts the build there; and .dockerignore
decides what reaches the container, so excluding a self-contained Go file
drops it from the lint silently.

The TODO.md entry loses its "Hardened" and "Corrected" paragraphs, which
argued with earlier versions of themselves, and keeps the flags, the durable
property, the three unguarded seams, and the evidence that the gate was
verified rather than assumed.
2026-08-10 13:38:15 +00:00
9f079ab594 Merge next: run all linting in Docker (closes #41)
Pins golangci-lint by digest in Dockerfile.lint and removes the host lint path entirely, killing the false-green class that started the issue.
2026-08-10 15:33:21 +02:00
3eb9f81fc4 docs: name the two required flags, and record .dockerignore as part of the gate
Comments and documentation only; the docker build invocation and its flags
are untouched, and .dockerignore's effective rules are unchanged.

Two prose gaps from review. First, "both flags below must stay" had lost
its anchor: it ended a paragraph naming only --no-cache-filter, --target
was not introduced until the next one, and three flags follow on the
command, so a reader could pick the wrong pair. It now names --target and
--no-cache-filter explicitly.

Second, the list of things the tooling does not check covered the $stage
seam but not .dockerignore, which sits in the same trust boundary and is
the more likely thing to be edited — the first review on this change
actively suggested extending it for build artifacts. Only what reaches the
container is linted, so excluding a Go source there removes it from the
lint with no warning. Verified rather than asserted: a planted violation
plus that one path in .dockerignore yields `0 issues.` at exit 0 with the
violation still in the working tree, while excluding a file other code
still references fails loudly on `undefined:` typecheck errors instead.
The warning is recorded in script/lint alongside the $stage seam and in
.dockerignore itself, where the edit would actually be made.
2026-08-10 13:29:44 +00:00
20cfb47912 build: define the lint stage name once so the two flags cannot diverge
The previous commit claimed --target and --no-cache-filter "validate each
other's magic string". They do not. --target validates only its own
argument; a typo confined to --no-cache-filter left the build green and
linting nothing:

    docker build --target lint --no-cache-filter=lnit ...
    #10 [lint 2/2] RUN golangci-lint run ... CACHED   exit 0

Three of the four edit paths were caught and one was not, so the original
false green survived in the narrow case.

The duplication was the defect: the stage name appeared twice on one
command line and nothing tied the copies together. Correcting only the
prose would have left the hazard live and merely warned about, so the name
is now written once, as `stage=lint`, and passed to both flags. Divergence
is unrepresentable rather than documented — there is a single name to get
wrong, and --target rejects it loudly when it is not a stage in
Dockerfile.lint, which now covers the filter too because it is the same
string.

The comments in script/lint and Dockerfile.lint and the TODO.md entry drop
the false "validate each other" claim and state the real property, along
with the residual hazard that is genuinely unguarded: --target checks that
the name exists, not that it names the stage which actually runs
golangci-lint, and it stops the build there, so relocating the lint step
or appending a stage after it would go unnoticed.
2026-08-10 13:11:42 +00:00
329c03f06e build: make the lint cache-busting self-validating in script/lint
`--no-cache-filter=lint` is silently ignored by BuildKit when no stage
matches the name, so the entire anti-false-green mechanism hung on one
unvalidated magic string: renaming or mistyping the `lint` stage would
have left the lint layer served from cache and `script/lint` reporting
green having linted nothing. Reproduced here — with the filter pointed at
a nonexistent stage and no `--target`, an unchanged tree built with
`RUN golangci-lint run ... CACHED` and exited 0.

`--target lint` closes it: a stage name that does not exist now fails
loudly (`target stage "nosuchstage" could not be found`, exit 1) instead
of passing. The two flags name the same stage from the same string and
validate each other; both the script and the stage definition in
Dockerfile.lint carry a comment saying they must be kept in sync.

`--output=type=cacheonly` drops the image export. Nothing consumes the
image — the deliverable of this build is an exit code — and the export
cost seconds per run and left one dangling image behind every time, on a
host where pruning is prohibited. The lint stage still executes and a
lint failure still exits non-zero, both verified rather than assumed.

TODO.md: the 2026-08-07 entry's claim that the repo has no linter pin and
lints on the host is marked superseded in place rather than rewritten;
the narrowed scaffold exemption now names `.dockerignore` alongside
`Dockerfile.lint` and `script/lint`; and the specific wall-clock timings
are replaced by the durable property they were evidence for, since they
vary per host and per run.
2026-08-10 12:55:40 +00:00
599286a88e build: run golangci-lint in a pinned container via script/lint (closes #41)
golangci-lint is no longer invoked on the host anywhere in the repo.
Dockerfile.lint pins golangci/golangci-lint:v2.12.2 by digest and runs
the linter as a build step, so a successful build IS a clean lint, and
`make lint` becomes a thin shim over script/lint. This removes the host
linter install that produced a false green here, where a branch that was
genuinely red with a goconst finding reported "0 issues" off the shared
host cache; a container per run has its own cache and lock.

Two deliberate divergences from the sneak/homoicon reference shape:

  - Two stages rather than one. A cached `deps` stage holds
    `go mod download`, then `FROM deps AS lint` carries the source copy
    and the lint run, and script/lint builds with
    `--no-cache-filter=lint`. Caching of the lint result is explicitly
    waived (a cached build lints nothing), and splitting the stages means
    busting the lint layer does not re-fetch the module cache over the
    network on every run.

  - No `golangci-lint config verify` step. It resolves its JSON schema
    over a live, unpinned HTTPS call: an unpinned network input inside
    the one step whose purpose is a pinned, reproducible gate, and a
    schema-host outage would surface as a red build. `golangci-lint run`
    already fails on a malformed config. The reason is recorded in a
    comment in Dockerfile.lint.

.dockerignore excludes .git only; the lint reads the Go sources,
go.mod/go.sum and .golangci.yml, none of which come from there.

The TODO.md scaffold-exemption note is narrowed rather than dropped:
Dockerfile.lint and script/lint are now permitted and required, while CI
config, REPO_POLICIES.md, an application Dockerfile and any other
script/ entrypoint still are not.

Verified, since a green docker build is the classic false green: two
consecutive script/lint runs on an unchanged tree each showed the
`golangci-lint run` layer executing (9.8s and 7.9s, both "0 issues.")
while the deps layers reported CACHED; a deliberate indent-error-flow
violation failed the build naming that finding and the unused one, and a
revert went clean again. `make check` green.
2026-08-10 12:33:41 +00:00
bde4eae450 Merge fix/autosave-turn-budget-36 (drive on a condition, not a turn budget) 2026-08-09 19:18:56 +02:00
clawbot
3061931291 test: drive the autosave race test to a condition, not a turn count (closes #36)
TestAutoSaveOnSignalRacesTurnLoop failed intermittently under load. The
captured failure text settles what it was: driveUntilDone's

    t.Fatal("the turn loop ran out of turns before the saves were taken")

with no WARNING: DATA RACE anywhere in the log. The handoff is fine; the
test's own drive loop ran out of its fixed 1000-turn budget first.

Confirmed by instrumenting the loop to report the turns it actually
used. The count tracks scheduling pressure and nothing else: about
60-120 turns at host load ~57 with the whole machine to spread over, 418
at GOMAXPROCS=4, 539 and 655 at 2 and 1, and past 1000 under the doubled
load of the verbose rerun the test target performs after a failure. The
turns spent between one save being answered and the next request
arriving are not work, they are the saving goroutine's wake-up latency,
so a fixed turn count is a wall-clock assumption in disguise. Raising it
would hide the flake, not fix it. Each old-code failure took 0.12
seconds - 1000 turns burned in a tenth of a second - which is why every
attempt to reproduce this by loading the host failed: the cap was never
a wall-clock allowance at all.

So the budget is gone rather than larger. driveUntilDone drives until
the saving goroutine finishes and nothing else. Termination still holds,
it just belongs to the code under test: every AutoSaveOnSignal returns
within the timeout it is handed, so the saving goroutine always
finishes. A handoff that has stopped answering costs one autoSaveWait in
total, because g.sigSave is one deep and an unserviced request stays in
the channel for every later call to find full and fail on at once; what
fails is then the real assertion, "saves taken = 0, want 25", rather
than "out of turns". That is not the worst case, and the comment states
the bound that actually holds: a handoff that drains each request but
slower than autoSaveWait costs one timeout per save, wantSaves *
autoSaveWait = 250s, which would run past the 30s package timeout. It
needs about ten seconds of scheduler starvation per save against the
0.12s-per-1000-turns regime above, so it is remote, and a turn cap did
not bound it either.

Removing the cap exposed a second assumption underneath it. testTerm
answers space and newline for ever once its script is exhausted, and
neither key takes a turn, so command(), which loops until the player
consumes one, never returns; the old cap was silently sized to the
script. An uncapped drive wedged inside a single command() call. The two
drive tests now use driveTerm, a headless terminal whose script repeats.
Repeating is necessary and not sufficient, and the comment on driveTerm
says which property is load-bearing: ' ' clears After outright and all
eight movement keys clear it on a refused step, so a script of only
those keys wedges exactly as testTerm's tail did - with the script set
to " " the drive hits the 30s timeout inside command(). What makes the
wedge impossible is that the cycle always contains an unconditional
turn-taker, and these scripts contain two, '.' and 's', neither of which
can be refused by being blocked in all directions, Held, in a bear trap,
or under NoCommand > 0. Trimming both out would bring the wedge back.

The guard is undiminished, shown by mutation and reverted afterwards.
Reverting the fix from the earlier signal-autosave work - AutoSaveOnSignal
replaced by a direct g.autoSave(), encoding on the calling goroutine -
still fails the test with a flood of DATA RACE reports (139 here, 62-110
on another machine; the property is what is pinned, not the number),
the encoder reading what the turn loop writes. Removing
serviceAutoSaveRequest from command() still fails it too, now in 10s
with "saves taken = 0, want 25" instead of by hanging.

Under load: at GOMAXPROCS=2 on a 48-core host at load ~150, with an
unrelated deliberate failure in the tree so every run took the verbose
rerun, the old code failed 8 of 8 runs and the new code 0 of 8. Also
green across 24 concurrent unconstrained runs at load ~120, 10 runs
alongside a spinner load, and 5 runs each at GOMAXPROCS 1, 2 and 4.

No non-test code changed. make check green, lint 0 issues, .golangci.yml
byte-identical.
2026-08-09 17:06:45 +00:00
13caec4298 Merge test/wizard-coverage (wizard commands verified against C) 2026-08-09 18:15:32 +02:00
df45f4cb24 Merge trap coverage (eight trap effects verified against C) 2026-08-09 18:15:08 +02:00
ba444a2002 Unit-test the eight trap effects against the C reference (closes #14)
`trapHandlers` had eight entries and zero direct tests, on the one
subsystem besides combat that can kill the hero outright. New
`game/traps_test.go` covers all eight arms of `move.c be_trapped`, the
prologue every trap runs through, and the `rust_armor` tail `T_RUST`
calls. Test-only: no game code changes.

Every expected value is transcribed from `origin/c-master` (`move.c`,
`misc.c`, `fight.c`, `monsters.c`, `rogue.h`) and quoted in the file. No
divergence from C was found.

The trap set is `rogue.h` 192-200: there is no separate "poison dart"
kind — `T_DART` is the poisoned dart — and `T_MYST`, the eleven-way
`rnd(11)` message switch, is the eighth. Details the tests are built
around: `BEARTIME`/`SLEEPTIME` are `spread(3)`/`spread(5)`, both of which
reduce to `rnd(0)` and so cost no random number, which is asserted as
well as their values; `T_ARROW` swings at `s_lvl - 1` and `T_DART` at
`s_lvl + 1`; and the strength loss is gated on `!ISWEARING(R_SUSTSTR) &&
!save(VS_POISON)`, whose short circuit means the ring saves a random draw
as well as the strength.

Damage dice and swing arguments are checked by sweeps rather than single
shots: `rnd(n)` is "raw value % n", so one draw cannot separate a d6 from
a d5, and a forced hit or miss cannot see a wrong `at_lvl`. Both shapes
were forced by mutation runs that the single-shot versions survived.

`be_trapped` takes a coordinate, and which coordinate decides whether
`T_TELEP`'s `mvaddch(tc, TRAP)` does anything. Sprung under the hero
(`move.go` 105-108, `case Floor`) the line is redundant: `tc` is the
hero's square, already stamped `TRAP` by the prologue and redrawn by
`teleport()`'s opening `mvaddch(hero, floor_at())`. Walked onto
(`move.go` 94-98, `case Trap`) it is the only writer: `tc` is the square
being stepped onto while the hero still stands on the previous one,
`leave_room` writes blanks and never `TRAP`, and the arm returns before
`finishMove` so no `look()` follows. Both shapes are tested.

The two death messages are deliberately uncovered: each is printed
immediately before `death()`, which reaches `myExit` and `os.Exit`, so
provoking either would kill the test binary. The hero is pinned with
`fortify()` and the damage is checked by replaying C's arithmetic. They
are the only two: `rust_armor`'s `|| ISWEARING(R_SUSTARM)` operand and
its `if (!to_death)` message suppression are covered as well.
2026-08-09 16:01:19 +00:00
6f409bda9e Cover the wizard commands with C-verified unit tests (closes #7)
game/wizard.go had no tests of its own. It 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, so a defect in either leaks into a normal game.

Adds coverage for createObj (pack filing and the gold arm),
createWeaponArmor, createRing, showMap, whatis, whatisPick, setKnow,
teleport and command.go's wizardKit. Package coverage 60.6% -> 62.4%.
Expected values are transcribed from wizard.c, command.c, extern.c,
weapons.c and rogue.h rather than read off the port; wizard mode is
entered through Params.Wizard, the field main.go fills from
ROGUE_WIZARD, so no test pokes the flag.

Two notes from the C. A wizard-created "cursed" weapon is not cursed in
either language: init_weapon assigns o_flags over the ISCURSED bit
create_obj had just set, leaving only the o_hplus penalty, and the test
pins the whole flag word to the init_dam[] row to say so. And show_map
turns standout on for a square missing F_REAL but off only for a square
whose whole flag word is zero. Exactly three sites clear F_REAL:
putpass, which sets F_PASS first and so leaves 0x80; door's secret-door
arm, on a room-wall exit still holding exactly F_REAL, leaving zero;
and new_level's trap loop, whose rnd(NTRAPS) is 0..7, so the T_DOOR
(00) case leaves zero as well. C's wstandend therefore does fire, 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. That display-only difference is reported on
the issue and left alone here, and the test asserts standout only up to
the first secret square.

No game behavior is changed.
2026-08-09 16:01:07 +00:00
c0741ad1ea Merge test/sticks-coverage (wand, staff and bolt-geometry tests) 2026-08-09 17:23:54 +02:00
29fbedb77d Test the wands, staffs and bolt geometry of sticks.c (closes #6)
game/sticks.go was the largest under-tested file in the repo: 534 lines,
23 functions and a single test. It now has two test files, both written
against the C reference (git show origin/c-master:sticks.c) rather than
against the current Go code, so they can catch divergence instead of
recording it.

game/sticks_test.go covers every zap handler that had none — light in a
room and in a corridor, drain-life's too-weak refusal (which returns
before o_charges--), drain's hit-point split and its kill arm,
drainReaches for all three of C's clauses, invisibility and the flytrap
release, polymorph's detach/re-attach dance with the pack, under-
character and delta-clobbering it does on the way, cancellation, both
teleport wands, magic missile, haste/slow in both directions, fix_stick's
damage and charge formulas, and charge_str.

game/bolt_test.go covers fire_bolt: dirch for all eight directions,
boltBounces including the door the hero stands on, an end-to-end flight
asserting the path and resting square, bounces off both wall
orientations, off a corner and diagonally off a wall (which pins C's rule
that a bounce negates both components rather than reflecting), a bounced
bolt striking the hero who fired it, the strike and miss arms, and the
dragon that shrugs off a flame but not a lightning bolt.

The tests read the flight path off the screen: fire_bolt paints its trail
and then paints chat() back over every square it recorded, so on an
otherwise blank screen the non-blank cells are exactly the squares the
bolt occupied, and the walls it bounced off are absent because C undoes
the record before the mvaddch. Determinism comes from a pinRng helper
that searches for a seed whose next draw is the wanted value, and from a
level the tests carve themselves with the generator's own drawRoom. The
hero is fortified wherever a bolt can reach him, since death exits the
process.

No divergence from C was found. Two notes are recorded in the test
comments: fire_bolt's "ch != 'M'" guard is a tautology, because winat is
t_disguise whenever a monster stands there, and the door-under-hero
exception can only be tested by the fact that the run terminates.
2026-08-09 15:11:02 +00:00
bf820e3ec9 Merge test/rings-coverage (ring tests verified against C) 2026-08-09 17:08:28 +02:00
c61e2827c5 Cover game/rings.go with C-verified unit tests (closes #5)
game/rings.go had no test coverage at all: not one of the suite's tests
touched wearing a ring, taking one off, choosing a hand, or the ring
contribution to the hunger clock. New game/rings_test.go covers ringOn,
pickRingHand, ringOff, gethand, ringEat and ringNum, plus the ring arm of
things.c dropcheck (dropRing), which is what actually removes a worn ring.
17 tests, 44 subtests; package coverage 53.7% -> 56.2%. No game code
changes.

Every expected value is transcribed from the C reference on origin/c-master
(rings.c, rogue.h, things.c) and quoted in the file, rather than from what
the port currently returns. No divergence from C was found.

ringEat is the reason this matters most: it feeds daemons.c's hunger clock,
so a wrong entry is a slow, silent drift in when the hero starves. All
fourteen ring kinds are pinned to C's uses[] table, both hands. The three C
subtleties are handled explicitly: a negative uses[] entry is a one-in-n
chance of a single unit and not a literal cost; R_DIGEST then flips the
sign, so slow digestion returns 0 or -1; and ring_num's switch closes with
the otherwise macro (rogue.h 53: break;default), so its four labels fall
through to one sprintf and every other kind returns "" from a default arm.

The chance rings are checked by snapshotting the generator, calling
ringEat, and replaying C's own expression from the identical state, which
pins the one-in-n denominator, the sign flip and the fact that exactly one
rnd call is spent; a frequency check over 4000 trials backs it. The
non-negative entries assert the opposite, that the generator is untouched,
because C never reaches rnd on that path and a stray call there would
desynchronise the game's RNG stream from C's.

Scripted hand answers carry an abort tail (a space for the reprompt's
--More--, then ESCAPE) so that a port which stopped accepting a key fails
on its assertion instead of looping forever on the headless terminal's
filler input. The "only one hand free" cases script the wrong hand key on
purpose: a port that prompted anyway would consume it and land the ring on
the wrong side.

Mutation-proved with 23 mutations, each reverted, each failing its own test
and only its own. All fourteen kinds are exercised; the eleven with no
wear-time effect in C are documented at the foot of the file as
deliberately not given a wear/remove test, and ring_off's unreachable "not
wearing such a ring" arm is documented as unreachable.
2026-08-09 14:53:46 +00:00
2f7a0d980d Merge audit/command-switch-coverage (pin dispatch to C's command switch) 2026-08-09 16:27:51 +02:00
2e02e7d190 test: pin command dispatch to C's command.c switch (closes #31)
Audits every case label in C's command.c against this port's dispatch and
leaves the audit behind as a standing test, so the two lists cannot drift
again. A missing dispatch entry is the one porting error that leaves no
trace at build time: the port is function-by-function, so every C function
has a Go counterpart and a dropped key dangles nothing, fails to compile
nowhere, and simply answers "illegal command" the first time a player
presses it. That is how '+' (#11) survived until PR #30 found it by
accident.

Result: no further missing keys. '+' was the only one. Recording that as a
negative result — the class of bug is real, it has now been searched for
exhaustively rather than stumbled upon, and the search came back empty.
Eighty labels: sixty-five in the main switch, fifteen in the wizard
sub-switch.

commandHandlers is pinned by set equality in both directions. A missing key
is the '+' bug; an extra key is the same bug mirrored, since the likeliest
way to acquire one is promoting a key out of the wizard sub-switch, which
would expose a MASTER debug command in ordinary play. The main-switch keys
that commandHandlers cannot hold - the goto-over re-dispatches, F-to-f, 'a'
and 'm' - are covered separately through dispatchKey, because dropping one
of those is just as silent as dropping a map entry.

Two traps are documented in the file. rogue.h 52-53 defines when as
break;case, so a grep for 'case ' finds ten of the eighty labels. And the
main/wizard split is load-bearing: '+' was a divergence in ordinary play,
not just wizard mode, precisely because it is a main-switch key.

Confirms this port targets the MASTER build: all four #ifdef MASTER sites
in command.c are ported unconditionally, as is sticks.c 237.
2026-08-09 14:27:26 +00:00
a653cc76f2 Merge fix/lost-c-behaviors (schtick message, forced redraw, greeting) 2026-08-09 12:13:59 +02:00
1142f43aed 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.
2026-08-09 10:04:54 +00:00
727dfb2642 Merge fix/wizard-toggle-off (restore the '+' wizard toggle) 2026-08-09 10:30:21 +02:00
c95f98ffe5 Port the '+' wizard-mode toggle-off (closes #11)
C's command.c 317-338 has a `when '+'` arm in the main command switch,
under #ifdef MASTER, that toggles wizard mode. The port had no '+' at
all, so the key fell through dispatchKey's default to illcom and
answered "illegal command '+'".

The password half of that arm was dropped deliberately (wizard mode is
ROGUE_WIZARD configuration) and is recorded in ARCHITECTURE.md section
9. The leave half was lost silently, and it is a different decision: it
does not touch the password machinery. The substantive part of it is
turn_see(TRUE) rather than the flag -- wizard sight draws every monster
the hero cannot see, so without the re-hide there is no way back to
normal visibility, and clearing the flag alone would leave the screen
lying.

New wizardToggleCommand, registered in commandHandlers between '^' and
Escape, which is C's own switch order. Because C's arm sits in the main
switch rather than the `if (wizard) switch (ch)` sub-switch that
wizardCommand ports, it is reachable whether or not wizard is set, so
the non-wizard case was a divergence too. It resolves the way the
dropped passwd() forces: a password check that no longer exists can
never succeed, so the else arm is what C did on a wrong answer -- the
message "sorry", with no prompt, since nothing typed into one could
change the outcome, and none of the noscore/turn_see(FALSE) bookkeeping
of C's unreachable success branch. The choice is stated in the doc
comment and in section 9.

Two tests drive '+' through g.dispatch. The wizard one spawns a phantom
(ISINVIS straight from the monster table, so seeMonst is false and it is
on screen only because wizard sight put it there), asserts the
precondition, then asserts the flag cleared, SenseMonsters cleared, the
cell restored to the map char under the monster with standout off, the
exact message text, and After false. Deleting the turnSee(true) call
fails it on all three visibility assertions. The other pins "sorry".

No RNG call is added: the turn_off arm of turn_see never reaches rnd.
TestSeedCompatItemTables is green against the untouched golden.
2026-08-09 08:22:05 +00:00
630038eedb Merge cleanup/pr26-followups (post-#26 doc and naming cleanups) 2026-08-09 10:13:19 +02:00
f7670cf86a docs,save: correct a stale TODO claim and rename encodeSnapshot (closes #27)
Four cleanups recorded as advisories during the PR #26 review and
deliberately kept out of it. No behaviour change.

The 2026-08-09 sig-leave (closes #12) TODO entry still argued in the
present tense that declining to save on SIGINT/SIGQUIT was also the safe
choice, "because AutoSave gob-encodes live state that the main goroutine
is still mutating, after removing the old file". Both halves stopped
being true with #24: the encode runs on the game goroutine and saveFile
is CreateTemp/Sync/Chmod/Rename with no Remove. The paragraph is now in
the past tense and marked superseded, pointing at the fix/autosave-race
entry, and says the split stands on C and on semantics alone — which is
what the current savesOnSignal comment says. The false claim was in the
#12 entry, not the #24 one; the latter's account of the old
remove-then-write is correctly historical and is untouched, as is the
err113 mention in the 2026-07-06 entry.

encodeSnapshot becomes writeSnapshotFile: it encodes, fsyncs, chmods
0400 and closes, and the old name claimed only the first of those. Its
doc comment now names all four and why the fsync is there.

TestAutoSaveOnSignalWhileInShellEscape used t.Error for a precondition,
so a save that was never taken fell through into assertRestorable, which
can then only report a second, derived failure. t.Fatal, matching the
identical assertion in the blocked-on-input test.

serviceAutoSaveRequest's doc comment carried a 24-column stub line
("The result is still a") left by an earlier edit. gofmt does not rewrap
comments, so fmt-check was legitimately green and nothing would have
caught it; the paragraph is rewrapped to the block's width.

Next Step deliberately not rotated: out-of-band issue work.
2026-08-09 08:05:30 +00:00
85354f2e6b Merge fix/autosave-race (service signal saves on the game goroutine) 2026-08-09 10:00:59 +02:00
clawbot
3a01283358 fix: take the signal-time autosave on the game goroutine (closes #24)
The SIGHUP/SIGTERM handler gob-encoded the live game tree from the signal
goroutine while the game goroutine was mid-turn mutating it, and AutoSave
removed the save file before encoding — so the failure mode was not a
stale save but a deleted one followed by a possibly torn replacement,
with a window in which the player had neither. The suite has run under
-race since 2026-08-09 and was green because nothing had ever driven the
turn loop concurrently with a signal: evidence of untested, not of safe.

The handler no longer writes anything. AutoSaveOnSignal posts a request,
wakes the input read, and waits up to signalSaveTimeout for the game
goroutine to take it; the encode runs on the goroutine that owns the
state, at the three points where that goroutine can sit: between turns
(command), on waking from a blocked readchar, and while parked in the `!`
shell escape (runShellEscape, which now runs the shell on a helper
goroutine so a hangup during it still rescues the game).

Blocked on input is the case that matters — a dropped connection lands
while the player is thinking, so a flag checked only between turns would
never be looked at. Terminal.ReadChar therefore returns (byte, bool),
with ok false meaning "woken by Interrupt, no key", and term.Tcell posts
a tcell.EventInterrupt onto tcell's own event queue to unpark PollEvent.
readchar services the request and reads again, so no caller sees it.

Running the shell on a helper goroutine would also have moved
term.Tcell.ShellEscape's panic on a failed Screen.Resume onto it, and a
panic at the top of any goroutine terminates the process without running
the deferred calls of the others — including cmd/rogue/main.go's
`defer t.Fini()`. The tty would have been left raw on precisely the path
where the terminal is already broken, which is issue #12's failure on a
path this change created. runShellEscape therefore recovers the helper's
panic and re-raises it on the game goroutine, whose stack has the restore
in it, so "every path restores the terminal via Terminal.Fini before
exiting" stays true.

saveFile writes a temporary file in the save's own directory, fsyncs it
and renames it over the target instead of truncating in place, so a save
that fails — or never happens because the deadline ran out — leaves the
player's previous save whole.

What the handoff guarantees is stated exactly rather than flatteringly:
the encode runs on the state-owning goroutine, so the snapshot is
internally consistent and restorable, but it is not necessarily taken
between commands. Only the check at the top of command is; the other two
service points both sit inside a command call already under way. readchar
is reached from mid-command prompts (--More--, askOverwrite, getStr, the
direction and pack prompts) with the command's mutations already applied,
and runShellEscape is reached from shell, an ordinary '!' command handler
dispatched inside command, with that turn's DoDaemons(Before) and
DoFuses(Before) already fired and its AFTER pass not yet. Restoring
re-enters playit at the top of command, so either way the rest of that
command is lost and a fresh BEFORE pass runs on top of the one in the
snapshot.

The SIGINT/SIGQUIT no-save decision and the single-signal-read ordering
guarantee are untouched. pendingSaver reads the game out from under its
mutex rather than delegating with it held, because the delegated call now
blocks until the save is taken.
2026-08-09 07:50:23 +00:00
e1bf46b241 Merge sig-leave (restore the terminal on INT/QUIT) 2026-08-09 08:19:01 +02:00
dfb34be1c4 fix: restore the terminal on SIGINT/SIGQUIT (closes #12)
The port installed handlers for SIGHUP and SIGTERM only, so SIGINT and
SIGQUIT killed the process with tcell still holding the tty and dropped
the user into a shell with no echo and a scrambled screen. All four
signals now go to one os/signal channel read by one goroutine, and every
path calls Terminal.Fini before os.Exit(0) -- C's leave(), "leave
quickly but curteously" (main.c).

The handlers are installed immediately after term.New(), the call that
raises raw mode, rather than after the game exists. Everything between
those two points ran raw with no handler at all: the save-restore path,
and -d's DeathDemo(), which never returns -- death() blocks in
waitFor('\n') (game/rip.go) -- so a kill -INT during the death demo left
exactly the scrambled terminal this fixes. The game is handed to the
handler afterwards through pendingSaver, whose AutoSave is a no-op until
then: a signal before the game is built restores the terminal and exits
with nothing to save. SIGHUP/SIGTERM autosave on the play path is
unchanged.

The save decision, written into the savesOnSignal comment: SIGHUP and
SIGTERM keep autosaving; SIGINT and SIGQUIT restore and exit without
saving. No path in C saves on INT or QUIT (leave() is endwin-and-exit,
quit() confirms/scores/exits, endit() goes through fatal(), and
save.c auto_save is reserved for HUP/TERM), the semantics agree
(involuntary teardown is worth rescuing a game from; a deliberate "stop
now" must not become a one-keystroke checkpoint against an anti-save-scum
save discipline), and it is the safe choice, since AutoSave gob-encodes
live state the main goroutine is still mutating after removing the old
file.

One reader of one signal is also what closes the corruption window: a
second signal arriving while a SIGHUP's AutoSave is mid-write stays
unread in the buffer instead of exiting out from under the writer.

cmd/rogue/main_test.go pins the membership of handledSignals() itself --
the rest of the file iterates that set, so without that assertion the
suite would pass against a set that had lost SIGINT and SIGQUIT again,
which is the regression this issue exists to prevent -- and covers the
ordering per signal, the save/no-save split (driven from the expectation
table so every entry is read), the mid-save second-signal interleaving,
the pre-game pendingSaver window, and real SIGINT/SIGQUIT/SIGHUP/SIGTERM
delivered to the test process through the same notifySignals wiring the
game uses.

Two premises behind the report were wrong and are recorded rather than
silently fixed: leave() is not installed on SIGINT/SIGQUIT during play
(the wiring is in mdport.c; the shipped build calls md_onsignal_default
and installs nothing, and leave() appears only in the endgame paths of
rip.c and main.c), and Ctrl-C never generated SIGINT here anyway, since
tcell's raw mode clears ISIG and the key arrives as byte 0x03 -- as it
did in C, whose setup() calls curses raw(). The real exposure is
kill -INT / kill -QUIT, a SIGINT to the process group while the ! shell
escape has the screen suspended, and the window after term.New()
described above. Nothing is raw before term.New(), so there was never
anything to cover there.

ARCHITECTURE.md section 9 gains rows for SIGTSTP/tstp() (deliberately
dropped: raw mode means Ctrl-Z cannot reach the process, suspending the
screen from the signal goroutine is a logical race against the drawing
goroutine -- not a data race, since tcell guards Suspend/Resume and Fini
alike -- and C armed tstp only after a successful restore(); the ! shell
escape covers the need), for SIGINT not routing to the interactive
quit() prompt, and for auto_save on the fault signals. Section 5.3's
claim that tcell handles SIGTSTP was false -- tcell registers only
SIGWINCH -- and is corrected, and its "every path restores the terminal"
claim now holds because of the install ordering above.
2026-08-09 06:07:22 +00:00
4aa4babe40 Merge fix/wizard-which-bounds (bound wizard-created item kinds) 2026-08-09 07:34:54 +02:00
af3050b187 fix: bound wizard-created Which against its item table (closes #10)
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.
2026-08-09 05:24:40 +00:00
eb31473ef0 Merge docs-staleness (correct stale MEMORY/README/TODO claims) 2026-08-09 07:00:35 +02:00
56bcad9fc6 docs: correct stale claims in MEMORY.md, TODO.md, and README.md (closes #3)
Four documented claims had gone false and were actively misdirecting agents
working this repo; the independent reviewer on PR #9 repeated one of them
verbatim. Each claim was re-verified against the tree before rewriting.

MEMORY.md "Error handling" described C's exit() calls being unwound by a
gameEnd panic recovered in Run. Refactor step 8 removed that: gameEnd appears
nowhere in the sources, myExit (game/rip.go) restores the terminal via
Terminal.Fini and calls os.Exit(0), and Run() has no return values and never
returns. The section now states that model and its testing consequence -- a
death exits the test binary, which is why tests drive command() directly and
crash sweeps pin the hero with fortify() in game/run_test.go.

MEMORY.md "Linting" said approved exceptions are recorded in a "Repo-specific
exceptions" block in .golangci.yml. There is no such block: the config is
byte-identical to canonical (sha256 021cc83f...46bcb) and the approvals live in
in-code //nolint directives carrying their dates. Following the old text would
have meant editing the canonical config. The same paragraph listed paralleltest
as an approved disable when it was fixed instead -- no paralleltest token
exists in the tree and all 32 tests call t.Parallel().

MEMORY.md "Debugging" and README.md both told the reader to run go test
directly. Since PR #9 the test target carries -timeout 30s -race -cover, so a
raw invocation silently drops the race detector while appearing to verify the
change. Both now point at make test / make check.

TODO.md asserted the host golangci-lint is "currently v2.12.2". It is v2.10.1
and the repo pins nothing, so the claim documented an accident of one machine.
Only the false claim is removed; the pin question is tracked separately.

Documentation only: no code, Makefile, or config change. Next Step is
deliberately not rotated, per the precedent for out-of-band issue work.
2026-08-09 04:59:00 +00:00
c922a16781 Merge make-test-policy-pattern (mandated test target pattern) 2026-08-09 03:50:26 +02:00
e376b2bf11 build: adopt the mandated test target pattern (closes #2)
The test: target was a bare `go test $(GO_PKGS)`, diverging from the
mandated shape in four ways: no -timeout 30s, no -race, no -cover, and no
conditional verbose rerun. It now runs

    go test -timeout 30s -race -cover $(GO_PKGS)

and, on failure, reruns with -v and then exits 1 — so the build still
fails even if a flaky test happens to pass on the second attempt. The
repo's existing $(GO_PKGS) variable is kept rather than hardcoding ./...,
and the recipe is @-prefixed so the rerun banner is the only noise.

The substance here is -race, not the Makefile edit: this is the first
time the suite has run under the race detector. It is clean, across five
consecutive uncached runs, including the tcell terminal layer and the
os.Exit-path playthrough tests that were the suspected risk.

Timing against the 20-second budget: 5.1s cold (including the race
build), ~2.3s warm. The failure path was exercised with a throwaway
failing test to confirm the verbose rerun fires and make exits non-zero.

Build tooling only; no game behavior change. .golangci.yml is untouched.
2026-08-09 01:42:24 +00:00
d6cd418f38 Merge pull request 'Update golangci-lint to v2.12.2 with canonical config' (#1) from golangci-v2.12.2 into main
Reviewed-on: #1
2026-08-07 23:24:38 +02:00
63d1e797e2 chore(lint): adopt canonical golangci-lint config
Replace .golangci.yml with the shared canonical config. The old
config's top-level linters-settings block was silently ignored under
the v2 schema, so the lll/funlen/cyclop/dupl thresholds now actually
apply. The four repo-specific disables (mnd, exhaustive, paralleltest,
testpackage) move out of the config into targeted in-code nolint
directives carrying their original approval dates, keeping the config
byte-identical to the canonical one.

Fixes surfaced by the stricter settings: t.Parallel() added to all 32
tests, 24 overlong lines wrapped or their comments tightened, tcell
control-code returns rewritten as character literals, dupl markers on
the identically-shaped item data tables, and two wsl_v5 defer cuddles.

No behavior changes. The repo has no golangci-lint version pin (no
Dockerfile or CI; make lint runs the host binary, currently v2.12.2),
so there was nothing to bump.
2026-08-07 20:45:02 +00:00
8ce238dd62 Merge seed-compat (item-table cross-validation vs C reference) 2026-07-24 03:05:39 +07:00
c30da22e43 Rotate TODO to coverage-broadening step (seed-compat item tables done) 2026-07-24 03:05:39 +07:00
e595b87718 Cross-validate item appearance tables against the C reference
Instrumented the C game on modern-rogue with a DUMP mode (patch in
testdata/c_seedcompat.patch) that forces the RNG seed and prints the
per-seed item appearance tables — potion colors, scroll names, ring
stones, wand/staff materials — in the normal init order, before initscr
so no terminal is needed. Captured its output for four seeds as
testdata/item_tables.golden.

TestSeedCompatItemTables regenerates the same tables from the Go port
via New(Params{Seed, Wizard: true}) and checks they match the golden
byte for byte. They do, for all four seeds — proving the LCG and its
consumption order through the whole init sequence (init_probs →
init_player → init_names → init_colors → init_stones → init_materials,
including init_player's arrow rnd(8)+rnd(15)) agree with C exactly.

testdata/README.md documents how to regenerate the golden.
2026-07-24 03:05:01 +07:00
11223caa7c Merge playtest-hardening (deep playthrough + crash sweep tests) 2026-07-23 08:59:03 +07:00
e7e1bc3c40 Rotate TODO to seed-verification step (playtest hardening done) 2026-07-23 08:59:03 +07:00
061da11877 Add deep-playthrough and turn-loop crash-sweep tests (playtest hardening)
Two death-safe regression drives that exercise the full turn loop within the
step-8 os.Exit constraint (a fortify() helper pins HP/food/exp and clears the
freeze/stuck counters each turn, so no death exits the test binary; fixed seeds
keep them deterministic):

- TestDeepPlaythrough: quaff/read/zap through command dispatch, then descend the
  staircase to depth 8 with a save/restore at depth 4 — a crash sweep of deep
  level generation, item effects, and mid-game save/restore. It asserts the
  consumables identify themselves (the commands really ran) and the descent and
  restore land where expected.
- TestTurnLoopCrashSweep: mash movement/search/rest for 200 turns on four seeds,
  exercising combat, monster AI, and traps.

Neither surfaced a panic. Space-separated command scripts answer the --More--
prompts, as wait_for consumes input up to a space.
2026-07-23 08:58:05 +07:00
0e6ed41351 Merge docs-refresh (ARCHITECTURE.md Part 2 + rename table) 2026-07-23 08:40:55 +07:00
b431af8b74 Rotate TODO to playtest step (docs refresh done) 2026-07-23 08:40:55 +07:00
cb1e302102 Refresh ARCHITECTURE.md Part 2 for the post-refactor design
Part 2 was written as a design sketch before the port was implemented and
refactored, so much of it described the planned code rather than the final
code. Updated the RogueGame/Stats/Object/Flags/Level sketches to the current
names and types (typed ObjectKind, DiceSpec, split o_arm fields, step-1 flag
names, TrapCount, Level list methods); rewrote §4.7 to say the static tables
now live on the per-game gameData struct (no package globals); noted the
daemon and effect handler tables (step 7), the MessageLine extraction (step 6),
the Terminal interface, the flat gob SaveState, and the New(Params) +
os.Exit-on-game-over design (step 8). Added §7.1, a C-name → Go-name rename
table, and a README note about the make targets. Docs only.
2026-07-23 08:40:15 +07:00
bcdfaf4ab4 Merge refactor/constructor-style (refactor step 8: New/Params + os.Exit) 2026-07-23 08:02:40 +07:00
a7d27ef65f Rotate TODO to docs-refresh step (step 8 done)
Step 8 complete: New(Params) constructor and os.Exit game-over. The
77-column wrap sweep was dropped per sneak. Docs refresh is now Next
Step.
2026-07-23 08:02:35 +07:00
194ce1dd16 Exit the process on game-over instead of unwinding a panic
One game run is one process, so game-over ends the process directly,
as the C game did with exit(). myExit now restores the terminal
(via the new Terminal.Fini) and calls os.Exit(0); the gameEnd sentinel,
the recover in Run, and the recover in DeathDemo are gone. Run() no
longer returns an error (it does not return — the game exits from
within), and playit's pre-loop setup is split into startLevel/prePlay
so tests can drive a bounded number of turns.

Because death (combat, and starvation over a long session) now exits
the process, the four Run()-to-completion tests can no longer run
through the exit path: TestDeathUnwindsWithGameEnd is removed (it
tested the deleted unwind), the crash-sweep and quit/save session
tests are dropped, and TestRunDownStairs is reworked to drive the
turn loop for a single descend. Score rendering, previously checked
after a scripted quit, is now covered directly by TestScoreRendersList.
Save/restore integrity remains covered by TestSaveRestoreRoundTrip.
2026-07-23 06:44:39 +07:00
cd0ba6c8ee Rename constructor to game.New(game.Params) per styleguide
NewGame(Config) becomes New(Params), and Restore takes Params too, so
the package's primary type gets the canonical New() constructor with a
named-field Params struct (styleguide points 139, 159). cmd/rogue and
all tests updated; ARCHITECTURE.md constructor references corrected.
Pure rename, suite green.
2026-07-23 05:39:31 +07:00
8241cf4bee Merge add-make-targets (dev Makefile + markdown formatting) 2026-07-22 22:20:45 +07:00
35b538e888 Add dev Makefile; format all markdown with prettier
Adds a minimal Makefile wrapping the toolchain the way sneak's other
repos expose it:

- fmt        gofmt -w plus prettier (4-space tabs, proseWrap: always)
- fmt-check  fail if any Go or Markdown file is unformatted
- lint       golangci-lint run ./...
- test       go test ./...
- check      fmt-check + lint + test (the local pre-commit gate)

Running make fmt normalizes the four existing Markdown docs to the
shared prettier style (80-column proseWrap: always, aligned tables) —
a one-time reflow with no content change. The repo remains exempt from
the rest of the policy scaffold (no Dockerfile, CI, or REPO_POLICIES).
2026-07-22 22:20:34 +07:00
88f18fc635 Merge refactor/effects-dispatch (refactor step 7) 2026-07-22 22:18:11 +07:00
ad098f9d99 Rotate TODO to step 8; note complexity linters clean (step 7 done)
Refactor step 7 is complete: the whole golangci-lint run is 0 issues.
MEMORY.md updated to reflect the complexity linters are enabled and
clean.
2026-07-22 22:18:11 +07:00
5b7e258195 Decompose test functions to clear complexity findings (step 7)
TestSaveRestoreRoundTrip, TestNewGameRandomizesAppearances, and
TestNewLevelInvariants split their assertion blocks into t.Helper()
sub-checks. The lint run is now completely clean (0 issues).
2026-07-22 22:11:10 +07:00
5c14a829aa Decompose tcell ReadChar key translation (refactor step 7)
ReadChar's key switch moves into translateKey with namedKey split into
motionKey/editingKey halves. term is complexity-clean. Behavior
unchanged.
2026-07-22 22:08:59 +07:00
b68836dde0 Decompose wizard.go createObj and whatis (refactor step 7)
createObj gains createWeaponArmor/createRing; whatis gains whatisPick
for its prompt loop. wizard.go is complexity-clean. Behavior unchanged.
2026-07-22 22:07:31 +07:00
730d91d160 Decompose ObjectKind.String and doMotion (refactor step 7)
String's tail moves into stringRest; doMotion's erase step moves into
eraseFlight and the inverted loop drops a nesting level. Behavior
unchanged.
2026-07-22 22:06:48 +07:00
71713d68b7 Decompose io.go End and status (refactor step 7)
End's --More-- handling moves into promptMore; status's redraw-skip
check moves into statusUnchanged. io.go is complexity-clean. Behavior
unchanged.
2026-07-22 22:05:06 +07:00
444bc30f2c Decompose ringOn and totalWinner (refactor step 7)
ringOn gains pickRingHand and chooseTerse messages; totalWinner's
appraisal switch becomes objectWorth with loreWorth/ringWorth/
wandWorth. Behavior unchanged.
2026-07-22 22:03:31 +07:00
ff7ee95395 Finish score/wakeMonster tidy (scoreSlot; drop unused return) 2026-07-22 22:02:02 +07:00
8895db530d Decompose score and wakeMonster (refactor step 7)
score splits into scoreInsert/scoreLines/showScores; wakeMonster gains
meanWakes/medusaCatches/medusaGaze predicates and effect. Behavior and
RNG call order unchanged.
2026-07-22 22:01:03 +07:00
3e1c30c787 Flatten digWallGap 2026-07-22 21:59:44 +07:00
432ea4f019 Decompose rooms.go (refactor step 7)
digRooms splits into digRoom/placeGoneRoom/placeMazeRoom/
placeNormalRoom/roomGold/roomMonster; dig gains digPick/digWallGap;
findFloorImpl gains floorChar; enterRoom and leaveRoom gain per-cell
helpers. rooms.go is complexity-clean. Behavior and RNG call order
unchanged.
2026-07-22 21:59:24 +07:00
bac9e361bc Decompose daemons.go; daemon dispatch becomes a table (step 7)
runDaemon's switch becomes gameData.daemonHandlers (the C d_func
function pointers restored as method expressions); stomach splits into
stomachFaint/stomachDigest; visuals gains visualMonsters. daemons.go
is complexity-clean. Behavior and RNG call order unchanged.
2026-07-22 21:57:22 +07:00
9083967ed3 Split applyHeader turn-state half; drop inline stat err 2026-07-22 21:55:04 +07:00
80484bcd31 Finish save.go split: header/player halves, drop inline err 2026-07-22 21:54:41 +07:00
43b4fbe746 Decompose save.go (refactor step 7)
saveGame splits into askDefaultSave/saveFileName/saveCheckOverwrite/
askOverwrite around a saveAnswer tri-state; snapshot gains destRefFor;
applySnapshot gains applyMonsters/applyDests. save.go is
complexity-clean. Behavior unchanged.
2026-07-22 21:53:02 +07:00
389db14bbf Finish addLine split (addLinePageBreak) 2026-07-22 21:51:06 +07:00
e1f065e783 Decompose things.go (refactor step 7)
inventoryName splits into nameScroll/nameFood/nameWeapon/nameArmor/
describeWorn/fixNameCase; newThing gains newFoodThing/newWeaponThing/
newArmorThing/newRingThing; dropCheck gains dropRing; addLine splits
into addLineSlow/addLinePaged/addLineOverlay. things.go is
complexity-clean. Behavior and RNG call order unchanged.
2026-07-22 21:50:44 +07:00
0b798c9c82 Tidy pack.go split: drop unused lp param and named returns 2026-07-07 03:31:53 +02:00
0274460e62 Decompose pack.go (refactor step 7)
addPack splits into pickupScareScroll and packInsert with
packScanKind/packScanWhich/packMatch/packMatchGroup for the C
linked-list walk; promptPackItem gains repeatLastItem and
promptItemPurpose; inventory's empty-handed messages flatten via
chooseTerse. pack.go is complexity-clean. Behavior unchanged.
2026-07-07 03:31:23 +02:00
c6dae3cf3d Finish getStr split (getStrResult) 2026-07-07 03:28:30 +02:00
5849dddcf0 Decompose options.go (refactor step 7)
ParseOpts splits into parseOptName/parseOptValue/parseInvType; getStr
gains endsInput/getStrErase/getStrEdit. options.go is complexity-clean.
Behavior unchanged.
2026-07-07 03:27:56 +02:00
cc2efb86e8 Decompose passages.go (refactor step 7)
digPassages gains pickNeighbor; connectRooms splits into
connOrient/connPlanDown/connPlanRight/connEnd/digCorridor around a
corridorPlan struct; addPass gains addPassSpot; the shared door/
secret-door predicate becomes hiddenExit. passages.go is
complexity-clean. Behavior and RNG call order unchanged.
2026-07-07 03:25:46 +02:00
ea68df32f0 Decompose move.go; traps become a handler table (refactor step 7)
The be_trapped switch becomes gameData.trapHandlers with one trap*
method per trap kind (mystery messages split in two); moveHero splits
into moveTarget/moveResolve/moveEnter/moveOnto/offMap; passageTurn
gains per-axis passageTurnVertical/Horizontal. move.go is
complexity-clean. Behavior and RNG call order unchanged.
2026-07-07 03:22:54 +02:00
fec79b939a Decompose chase.go (refactor step 7)
chase splits into chaseBestSpot/chaseTry/scareScrollAt with a
chaseSearch state struct; chaseStep gains chaseRooms, chaseGoal,
dragonBreath/dragonShoots, and chaseTakeObject; runners gains
runnerTurn; findDest gains objectClaimed. chase.go is complexity-clean.
Behavior and RNG call order unchanged.
2026-07-07 03:19:31 +02:00
aa57349c34 Decompose command.go; command keys become a handler table (step 7)
The ordinary command keys move into gameData.commandHandlers (method
expressions and small literals); dispatchKey keeps only re-dispatching
prefixes (runCommand/fightCommand/repeatCommand/moveOnCommand) and the
wizard fallthrough. command() splits into playTurn/turnUpkeep/
readCommand/executeCommand/countPrefix/ringTurnEffects; search gains
searchSpot/searchFloor; help gains helpOne/helpAll/helpLines; call
gains callTarget/callPrelude; wizardCommand splits in two plus
wizardKit; uLevel and current flatten to early returns. command.go is
complexity-clean. Behavior and RNG call order unchanged.
2026-07-07 03:15:27 +02:00
4a248eb392 Decompose look and promptDirection (refactor step 7)
look's nine-square scan splits into lookAround/lookCell with a
lookScan state struct and guard helpers (lookSkips,
lookForeignPassage, lookDiagonalBlocked, lookCellChar, lookShow,
lookRunCheck, atRunEdge); promptDirection gains deltaFor and
confuseDirection. misc.go is complexity-clean. Behavior and RNG call
order unchanged.
2026-07-07 03:03:50 +02:00
a20f500655 Decompose fight.go: hit-handler table, weapon/armor helpers (step 7)
The monster special-power switch in attack becomes
gameData.hitHandlers (indexed by monster letter); attack splits into
monsterHit/monsterMiss; fight gains revealXeroc and heroHits;
rollAttacks gains weaponAttack, wieldedRingBonus, and defenderArmor;
killed gains killedSpecial. fight.go is complexity-clean. Behavior and
RNG call order unchanged.
2026-07-07 02:58:44 +02:00
ebe477ba28 Decompose remaining effects-file hot spots (refactor step 7)
drain gains drainReaches; zapSpeed gains hasteTarget/slowTarget;
readHoldMonster gains holdMonstersNear; readCreateMonster gains
createMonsterSpot; revealSpot splits into revealChar/revealWall/
revealSolid/revealFloor; turnSee gains showSensed. potions.go,
scrolls.go, and sticks.go are complexity-clean. Behavior and RNG call
order unchanged.
2026-07-07 02:53:09 +02:00
1a25beead8 Decompose fireBolt (refactor step 7)
The fire_bolt loop splits into boltDirChar, boltBounces,
boltStrikesMonster, and boltStrikesHero; loop state (hitHero/changed/
used) stays in fireBolt. Effect order and RNG calls unchanged.
2026-07-07 02:49:37 +02:00
8e2915f60d Convert doZap to a per-wand handler table (refactor step 7)
The do_zap switch becomes gameData.zapHandlers, indexed by WandKind;
the shared monster-ray preamble is zapRayMonster/zapVictim, teleport
away/to and the three bolt wands share handlers, and a false return
aborts the zap without spending a charge (drain life on a too-weak
hero, as in C). Effect order and RNG call sequence unchanged.
2026-07-07 02:48:01 +02:00
3047f729aa Convert readScroll to a per-scroll handler table (refactor step 7)
The read_scroll switch becomes gameData.readHandlers, indexed by
ScrollKind; the five identify scrolls share one handler. The magic
mapping cell logic is extracted into revealSpot. Effect order and RNG
call sequence unchanged.
2026-07-07 02:46:09 +02:00
cc025eb808 Convert quaff to a per-potion handler table (refactor step 7)
The quaff switch becomes gameData.quaffHandlers, a method-expression
table indexed by PotionKind; each case body moved verbatim into a
quaff* method. Effect order and RNG call sequence unchanged.
2026-07-07 02:44:33 +02:00
acef593288 Merge refactor/god-object-extraction (refactor step 6) 2026-07-07 02:42:18 +02:00
0b56ac8019 Extract MessageLine, Player pack ops, and Level list management
Refactor step 6. MessageLine (was MsgLine) owns the msg/addmsg/endmsg
machinery, wired to its screen, pre---More-- redraw, and input via
attach(); RogueGame keeps one-line msg/addmsgf/endmsg shorthands so
the ~400 call sites are unchanged. Player gains nextPackChar and
removeFromPack (the state half of pack.c leave_pack); leavePack keeps
only the LastPick repeat-command tracking. Level gains ObjectAt
(misc.c find_obj) and AddObject/RemoveObject/AddMonster/RemoveMonster,
replacing direct attach/detach calls on the level lists. Inventory and
pickup UI flows stay on RogueGame: display and orchestration, not
state surgery. Behavior and RNG order unchanged; suite green.
2026-07-07 02:42:18 +02:00
a094f7c6c3 Merge refactor/fix-nonamedreturns 2026-07-07 02:35:18 +02:00
0caaa14198 Drop unused named return on moveMonster (nonamedreturns) 2026-07-07 02:35:18 +02:00
d3ef07cfa7 Merge refactor/item-combat-ui-renames (refactor step 5) 2026-07-07 02:34:32 +02:00
f432c8718c Rename getDir to promptDirection; rotate TODO (step 5 done) 2026-07-07 02:34:32 +02:00
ae79fd5e84 Rename combat methods; -1 status codes become named bool results
Refactor step 5, combat: rollEm→rollAttacks; attack, moveMonster, and
chaseStep return (removed bool) instead of the C -1/0 int codes.
Behavior unchanged; suite green.
2026-07-07 02:33:15 +02:00
6d798c56ed Rename item-subsystem methods (refactor step 5, items)
getItem→promptPackItem now returns (obj, ok) instead of a nil-signaling
pointer; invName→inventoryName; doPot→applyPotionFuse. C breadcrumbs
kept. Behavior unchanged; suite green.
2026-07-07 02:31:38 +02:00
0554f5d4f1 Merge refactor/movement-renames (refactor step 4) 2026-07-07 02:29:06 +02:00
6850c87ae7 Rename movement/world methods to idiomatic Go; remove all gotos
Refactor step 4. Renames (C breadcrumbs kept in doc comments):
doMove→moveHero, beTrapped→springTrap, rndmove→randomStep,
doRooms→digRooms, doPassages→digPassages, doMaze→digMaze,
chgStr→changeStrength, doRun→startRun, moveStuff→finishMove,
turnref→turnRefresh, moveMonst→moveMonster, doChase→chaseStep,
setOldch→setOldChar, cansee→canSee, roomin→roomIn, runto→runTo,
conn→connectRooms, putpass→putPassage, passnum→numberPassages,
numpass→numberPassage, rndPos→randomPos, rndRoom→randomRoom,
treasRoom→treasureRoom, accntMaze→accountMaze.

All goto/label flows are gone: moveHero uses a retry loop with the
PASSGO corner logic extracted into passageTurn; dispatch re-dispatches
via a loop; chaseStep re-checks via a loop; saveGame uses a labeled
prompt loop. Control flow and RNG call order are unchanged; suite
green.
2026-07-07 02:29:06 +02:00
65a1cd68b8 Merge refactor/drop-unused-nolints 2026-07-07 02:17:25 +02:00
525465a68b Drop two unused gosec nolint directives in chooseSeed
The 0x7fffffff mask makes both int32 conversions provably safe, so
G115 never fired; nolintlint flags the directives as unused.
2026-07-07 02:17:25 +02:00
a49d857970 Merge refactor/lint-mnd-disable (mnd disabled per approval) 2026-07-07 02:16:38 +02:00
32067eb318 Disable mnd with sneak's approval (2026-07-07)
The 289 findings are C-faithful gameplay literals (probability rolls,
damage spreads, screen coordinates); naming them would invent constants
the C never had and hurt greppability against the reference sources.
2026-07-07 02:16:38 +02:00
d6aa74d9f1 Merge refactor/no-package-globals (all globals into gameData, lint adoption done) 2026-07-07 02:11:04 +02:00
a8feb6c05d Move all package-level vars into gameData; finish lint adoption
gochecknoglobals: all 37 package-level tables consolidated into the
gameData struct (game/tables.go), built by newGameData() and carried
on RogueGame as g.data (set in NewGame and Restore). ObjectKind
Glyph()/objectKindForGlyph are now switches; the table-reading subtype
Stringer methods are gone; isMagic is a RogueGame method.

goconst: repeated words named (potionName/scrollName/ringName/goldName
in object.go, wandName/staffName in sticks.go, ripWall in tables.go).

exhaustive, testpackage: disabled in .golangci.yml with sneak's
approval (2026-07-07).

Also reverts misspell's silent corruption of the "ther" scroll-name
syllable (it had become "there", changing generated scroll names vs C).

Remaining red: cyclop/gocognit/nestif until refactor step 7; mnd
awaits a ruling. TODO.md rotated; MEMORY.md lint notes updated.
2026-07-07 02:10:58 +02:00
50afbec8e3 Merge refactor/lint-adoption (house lint config, findings fixed) 2026-07-07 00:03:45 +02:00
5ba9fe8f66 Adopt house golangci-lint config; fix all approved-linter findings
.golangci.yml is the prompts-repo standard verbatim plus one approved
exception (paralleltest, sneak 2026-07-06). Roughly 1,500 findings
fixed:

- autofix sweep (wsl_v5/nlreturn/intrange/modernize formatting), with
  the misspell autofix REVERTED where it rewrote authentic C game text
  ("missle vanishes" stays, with nolint and a comment)
- error handling: errcheck sites get real handling — saveFile now
  closes explicitly and removes corrupt saves, scoreboard writes are
  documented best-effort, err113 sentinel errors (ErrSaveOutOfDate,
  ErrScreenTooSmall); panics allowed for unrecoverable states per
  MEMORY.md policy
- gosec: real fixes (ParseInt for SEED, 0600 scorefile) and justified
  per-line nolints for provably-bounded conversions (randomMonsterLetter
  helper collapses eight rnd(26)+'A' sites)
- API tidying from linters: pointer receivers on flag types
  (recvcheck), msg helpers renamed addmsgf/doaddf/Printwf/MvPrintwf
  (goprintffuncname), myExit()/findFloor(monst)/mkGameInput(t) drop
  always-constant params (unparam), gameEnd carries no status
- gocritic/staticcheck: if-else chains to switches, the pack.c
  inventory filter untangled into matchesFilter, main.go split so
  defers run before exit (exitAfterDefer, funlen)
- revive doc comments on all exported flag types/consts/methods

Remaining findings are confined to linters pending sneak's exception
decision (mnd, gochecknoglobals, cyclop, nestif, gocognit, exhaustive,
goconst, testpackage); TODO.md records the state. MEMORY.md added at
repo root as the project's agent working notes.
2026-07-07 00:03:45 +02:00
3ed7931676 Merge refactor/object-fields (refactor step 3 + module rename) 2026-07-06 23:10:39 +02:00
43c59b7f82 Update module base path to git.eeqj.de/sneak/rgoue
go.mod, term/ and cmd/ imports, the ARCHITECTURE.md module references,
and the in-game version string. User request; the repo already lives
at this remote.
2026-07-06 23:10:39 +02:00
2eff377a73 Un-overload Object fields and pre-parse damage dice (refactor step 3)
Object.Arm carried four meanings in C (o_arm/o_charges/o_goldval plus
ring bonuses); it is now four fields: ArmorClass, Charges, GoldValue,
and Bonus. Stats.Arm becomes Stats.ArmorClass. The Charges()/GoldVal()
accessor pair is gone.

Damage dice strings ("1x4/1x2") are parsed once into DiceSpec — at
table definition for the bestiary and weapon tables, at creation for
items — instead of re-parsed with atoi on every swing as fight.c
roll_em did. ParseDice preserves the C parse semantics exactly,
including the junk-tolerant "%%%x0" bestiary placeholder and the
"000x0" flytrap reset (regression-tested in dice_test.go); the
flytrap's growing grip becomes DiceSpec{{VfHit, 1}}.

Save format bumps to 5.4.4-go3 (field renames and retypes would
silently zero under gob's match-by-name decoding).

No behavior change; full suite green.
2026-07-06 23:07:57 +02:00
c75af2ec22 Rotate TODO.md for completed refactor step 2
The rotation should have ridden the step-2 commit per the Workflow
section, but the file was edited concurrently and the update was lost;
recording it now. Step 3 (un-overload Object fields) is Next Step.
2026-07-06 22:01:33 +02:00
4b04ac08ea Merge refactor/typed-kinds (refactor step 2) 2026-07-06 22:00:43 +02:00
b940cfc41f Give item categories and subtypes real types (refactor step 2)
ObjectKind separates what an item is from how it draws: Object.Type
(a byte that doubled as the map character) becomes Kind ObjectKind,
with Glyph() producing the display character and objectKindForGlyph
converting back at the map boundary. KindWand covers the C 'stick'
category. The magic-missile bolt uses KindGold, matching C's literal
o_type='*' trick, with a comment.

PotionKind, ScrollKind, RingKind, WandKind, WeaponKind, ArmorKind and
TrapKind are now iota enums with Stringer (names come from the base
info tables); Object gains typed accessors (obj.RingKind(), ...) and
Launch is typed WeaponKind. beTrapped returns TrapKind. The
getItem/inventory/whatis prompt filters take ObjectKind, with
KindCallable/KindRingOrStick replacing the C CALLABLE/R_OR_S
sentinels; wizard.c's type_name table is subsumed by
ObjectKind.String(). IsRing/IsWearing/initWeapon/doPot signatures are
typed accordingly.

The save format version becomes 5.4.4-go2: the gob field rename would
otherwise silently zero Kind when reading old saves.

No behavior change; full suite green.
2026-07-06 22:00:43 +02:00
626f8c5a7d Merge refactor/descriptive-constants (refactor step 1) 2026-07-06 21:29:19 +02:00
e71a2ef3fd Rename constants to descriptive names (refactor step 1)
Pure rename, no behavior change; the full suite (RNG goldens,
generation invariants, scripted sessions, save round trip) is
unchanged and green.

- Creature flags: IsHuh→Confused, IsHalu→Hallucinating,
  CanHuh→CanConfuse, CanSee→CanSeeInvisible, IsRun→Awake,
  SeeMonst→SenseMonsters, IsCanc→Cancelled, IsLevit→Levitating,
  IsBlind/IsGreed/IsHaste/IsTarget/IsHeld/IsInvis/IsMean/IsRegen/
  IsFly/IsSlow → Blind/Greedy/Hasted/Targeted/Held/Invisible/Mean/
  Regenerates/Flying/Slowed, IsFound→Found
- Object flags: IsCursed→Cursed, IsKnow→Known, IsMissl→Missile,
  IsMany→Stackable, ObjIsFound→WasFound, IsProt→Protected
- Room flags: IsDark/IsGone/IsMaze → Dark/Gone/Maze; place flags:
  FPass→FPassage, FPNum→FPassNum, FTMask→FTrapMask
- Trap types: TDoor→TrapDoor ... TMyst→TrapMystery
- Item subtypes: P*→Potion* (PLSD→PotionLSD, PMFind→
  PotionDetectMonsters, ...), S*→Scroll* (SIDRorS→
  ScrollIdentifyRingOrStick, ...), R*→Ring* (RAddHit→RingDexterity,
  RNop→RingAdornment, ...), Ws*→Wand* (WsElect→WandLightning, ...),
  weapons (TwoSword→WeaponTwoHandedSword, Shiraken→WeaponShuriken,
  ...), armor (RingMail→ArmorRingMail, ...)
- Counts: Max{Potions,Scrolls,Rings,Sticks,Weapons,Armors} →
  Num{Potion,Scroll,Ring,Wand,Weapon,Armor}Types; NTraps→NumTrapTypes
- Level.NTraps field → TrapCount (also in the save snapshot)
- Original C constant names preserved as comment breadcrumbs in
  types.go so the lineage stays greppable

TODO.md: step 1 moved to Completed, step 2 (typed kinds) promoted to
Next Step.
2026-07-06 21:28:57 +02:00
aaf72cf894 Add standard Workflow section to TODO.md 2026-07-06 21:06:38 +02:00
1133feb0ba Plan the idiomatic-Go refactor in TODO.md
Itemize the refactor from transliterated port to idiomatic Go: eight
stepped feature branches (descriptive constant renames, typed kind
enums, un-overloaded Object fields, per-subsystem method renames,
god-object extraction, effects dispatch tables, constructor/style
pass, docs refresh), slotted ahead of the existing playtest/lint/
release steps. Also add the styleguide-required .gitignore (a stray
built binary was sitting untracked in the root).
2026-07-06 20:54:32 +02:00
2bcc3894e2 Add TODO.md 2026-07-06 20:46:57 +02:00
c0b533ed2f Make the rgoue branch Go-only
The port is complete, so the C sources and their build system
(autoconf/automake inputs, Makefiles, Visual Studio project, doc
templates, RPM spec, C build/modernization notes) leave this branch;
they live on in master/modern-rogue for reference.

Also ports the last missing wizard command (things.c pr_list/pr_spec,
the '*' item-probability listing), rewrites README.md for the Go port,
and updates ARCHITECTURE.md's layout notes (game/ has no third-party
imports; tcell lives in term/).

Kept: LICENSE.TXT, rogue.png, rogue.desktop, ARCHITECTURE.md.
2026-07-06 20:19:33 +02:00
41fc1042fc go: port command loop, save/restore, tcell terminal, and the binary
- command.c in full: the command dispatcher with repeat counts, run
  prefixes, ctrl-run door-stop mode, fight-to-death targeting, and all
  wizard debug commands; search, help, identify, d_level/u_level, call,
  current
- main.c completed: Run()/playit() with the C double ROGUEOPTS parse;
  exit()-anywhere becomes a gameEnd panic recovered in Run
- save.c + state.c: gob SaveState snapshot with explicit pointer-to-
  index fixups for equipment, monster rooms, and chase targets (the
  rs_fix_thing role); save file deleted on restore as in C; SIGHUP/
  SIGTERM autosave hook
- wizard.c completed (create_obj, show_map), passages.c add_pass
- term/: tcell/v2 Terminal — the one third-party dependency — replacing
  curses and mdport's 900-line key decoder; shell escape via suspend
- cmd/rogue: flags -s/-d, SEED (wizard), ROGUEOPTS, ROGUE_WIZARD

Tests: full scripted sessions through Run (quit, descend stairs,
3200-move crash sweep, save-command round trip). Notable fixes found
by tests: gob silently drops embedded fields of unexported types, and
--More-- prompts swallow space-free input scripts.
2026-07-06 20:15:47 +02:00
cdf9bf73a9 go: port item effects — potions, scrolls, options, call_it
- potions.c completed: quaff with all 14 potion effects, the PACT
  standard-fuse table (do_pot), raise_level; the see-invisible fruit-
  juice message is computed at quaff time as in C
- scrolls.c in full: read_scroll with all 18 scroll effects including
  the magic-mapping map rewrite and identify dispatch, uncurse
- options.c in full: the interactive options screen, get_bool/get_str/
  get_inv_t/get_sf editors, ROGUEOPTS parsing (prefix matching, no-
  prefixed booleans, ~ expansion), strucpy
- misc.c call_it via the get_str line editor
- turn_see gets a DaemonID (C casts it to a fuse callback for the
  monster-detection potion)

Tests: healing/confusion potions with fuse burn-down, enchant armor,
hold monster, slow-monster zap, ROGUEOPTS parsing, and a regression
test documenting the C wake_monster ISGREED/ISHELD quirk the port
keeps faithfully.
2026-07-06 19:34:36 +02:00
3c5add87cd go: port combat, chase driver, traps, zapping, death and scores
- fight.c in full: fight/attack with all eight special monster attacks
  (aquator rust, ice freeze, rattlesnake poison, wraith/vampire drains,
  flytrap hold, leprechaun/nymph theft), swing, roll_em dice parsing
  (with a C-semantics atoi — Go's strconv rejects '1x4/...'), hit/miss
  message variants, killed, remove_mon
- chase.c completed: runners, move_monst, relocate, do_chase with
  dragon breath, chase target selection (C's dead oroom comparison in
  relocate is preserved as-is)
- move.c in full: do_move with passgo turning, all eight traps,
  rndmove, rust_armor
- sticks.c completed: do_zap (all 14 sticks), drain, fire_bolt with
  wall bounces; weapons.c completed: missile/do_motion/fall/wield
- rip.c: death tombstone, total_winner, killname; C exit() becomes a
  gameEnd panic that Run will recover
- score.go: top-ten scoreboard as a gob file with lock-file protocol
  replacing the XOR-encrypted C format
- wizard.c: whatis/set_know/teleport; daemons.c stomach
- monster bestiary moved to per-game state (C mutates the flytrap
  damage string during play)

Tests: combat math, kill/removal bookkeeping, monster chase pursuit,
death unwinding, scripted-input headless terminal.
2026-07-06 19:23:45 +02:00
a69ef7dc04 go: port dungeon generation, items base, pack, and monster creation
rooms.c, passages.c, new_level.c ported in full: room/maze layout,
corridor spanning tree with extra connections, passage numbering flood
fill, trap/stairs/object placement, treasure rooms. Careful RNG-call
ordering throughout keeps generation seed-faithful to C.

Supporting subsystems this depends on, also ported:
- screen.go: curses replaced by in-memory Window cell buffers behind a
  Terminal interface (tcell arrives with the UI phase; tests run headless)
- io.go: msg/addmsg/endmsg message-line protocol, status line, step_ok
- pack.c in full (add_pack sorting/stacking walk, get_item, inventory)
- things.c in full (inv_name, new_thing, discovery lists, pagination)
- monsters.c in full; chase.c navigation half (roomin/cansee/see_monst/
  find_dest/runto/set_oldch); rings.c, armor.c in full
- weapons.c/sticks.c creation half (init_weapon, fix_stick, num)
- misc.c look() display update, eat, level-up, direction input
- daemons.c callbacks except stomach (needs death()) and the runners
  chase driver (combat phase)
- init.c init_player with the starting kit

Tests: level invariants across seeds, determinism, 30-depth sweep.
2026-07-06 19:05:46 +02:00
7fa2048402 go: port foundation — types, RNG, tables, daemon scheduler
Module sneak.berlin/go/rogue, package game:
- types.go: all rogue.h constants, flag types, Coord/Stats/Room/ObjInfo
- creature.go/object.go: the THING union split into Creature (Player/
  Monster) and Object; slices replace the C linked lists
- level.go: Place map with the C column-major layout and chat/flat/moat/
  winat access methods
- tables.go: extern.c + init.c data (bestiary, item tables, name pools)
- rng.go: the exact C LCG (seed*11109+13849), golden-tested against a
  compiled C reference for seed compatibility
- init.go: per-game item appearance randomization (init.c)
- daemon.go/daemons.go: scheduler with DaemonID replacing function
  pointers (ids match the C save format's mapping)
- game.go: RogueGame holds all former globals; NewGame constructor
2026-07-06 18:46:22 +02:00
45dba95678 Add ARCHITECTURE.md Part 2: Go OOP port design
Design for the Go port (module sneak.berlin/go/rogue):
- RogueGame instance type owns all former globals; no package globals
- THING union split into Creature (Player/Monster) and Object
- Slices replace THING linked lists; list.c is not ported
- Daemon function pointers become serializable DaemonID enum + dispatch
- tcell/v2 (sole third-party dep) replaces curses; Window cell buffers
  preserve the screen-as-data-structure idiom
- encoding/gob snapshot replaces the 2,134-line state.c serializer
- Exact-LCG RNG for seed-compatible dungeon generation, with golden tests
- Per-file porting map, naming conventions, porting order, drop list
2026-07-06 18:17:48 +02:00
91eeee09c5 Add ARCHITECTURE.md Part 1: complete map of the C program
Documents the entire Rogue 5.4.4 C codebase ahead of the Go port:
program flow, source file inventory, the full type system (THING
union, PLACE map, rooms, stats, obj_info, daemons), constants and
flag bits, the macro layer, a census of every global variable, and
a function-by-function catalog of all 36 source files.
2026-07-06 18:13:55 +02:00
David Silva
546829b745 Merge pull request #14 from ndrew222/modern-rogue
change CFLAGS to compile on Linux
2026-05-14 20:59:09 +01:00
ndrew222
3554e0192e change CFLAGS to compile on Linux 2026-01-26 11:46:29 +08:00
David Silva
4eb2e8ae8c Improve README installation instructions for newcomers
- Reorganize Prerequisites section with numbered items for clarity
- Emphasize that ncurses development package is required (not just runtime)
- Update package names: add libncurses-dev for modern Debian/Ubuntu
- Add Fedora-specific instructions using dnf
- Add 'Before you begin' note in Quick Start section
- Improve troubleshooting section with clearer explanations
- Enhance platform-specific notes for macOS Homebrew
2025-11-07 15:51:27 +00:00
David Silva
97aee89f9c Update date in second modernization iteration entry
- Change date from placeholder 2025-01-XX to 2025-11-07 for function prototype fixes
2025-11-07 15:37:05 +00:00
David Silva
f564a46c1c Update modernization log: add dates and cleanup whitespace
- Update date from placeholder to 2025-11-07 in MODERNIZATION_LOG.md
- Mark function prototype warnings as fixed in pending issues
- Remove trailing whitespace in daemon.c, rogue.h, state.c
- Clean up formatting in MODERNIZATION_LOG.md
2025-11-07 15:36:58 +00:00
David Silva
a0c66a6078 Fix function prototype warnings for C23 compatibility
- Add proper function prototypes: fatal(), my_exit(), set_order()
- Fix function pointer types to match call sites: void (*func)(int)
- Remove conflicting localtime() declaration in rip.c
- Fix incorrect new_item() call in state.c (remove unused argument)

Result: Zero compilation warnings, C23 compatible.

See MODERNIZATION_LOG.md for detailed analysis.
2025-11-07 15:33:46 +00:00
David Silva
b66c6659c9 Fix ncurses compatibility: remove internal structure access
- Remove direct access to curscr->_cury and curscr->_curx
- Replace with public API call move() for cursor positioning
- Fixes critical build failure on modern systems with ncurses 6.x
- Build now succeeds on macOS arm64

See MODERNIZATION_LOG.md for detailed reasoning and impact analysis.
2025-11-07 15:30:46 +00:00
125 changed files with 25043 additions and 32928 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
# Part of the lint gate: only what reaches the container is linted, so
# excluding a self-contained Go source here drops it from the lint silently.
# Never exclude Go sources, go.mod/go.sum or .golangci.yml.
.git
# Generated artifacts only; `make build` puts a multi-megabyte binary here
# and it would otherwise be shipped into the build context.
/build/

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
*.log
*.out
*.test
/build/
/rogue

34
.golangci.yml Normal file
View File

@@ -0,0 +1,34 @@
version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run:
timeout: 5m
modules-download-mode: readonly
linters:
default: all
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
max-issues-per-linter: 0
max-same-issues: 0

1875
ARCHITECTURE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,125 +0,0 @@
# Build Issues Found During README Testing
This document lists issues encountered when following the README.md build instructions on macOS (darwin 25.0.0, Apple Silicon).
## Date
Testing performed: Following README.md Quick Start instructions
## Environment
- **OS**: macOS (darwin 25.0.0)
- **Architecture**: Apple Silicon (arm64)
- **Compiler**: GCC/Clang (available)
- **make**: Available
- **ncurses**: Installed via Homebrew at `/opt/homebrew/opt/ncurses`
## Issues Found
### 1. Compilation Error: Incomplete Type 'WINDOW'
**Location**: `main.c:241-242`
**Error Message**:
```
main.c:241:11: error: incomplete definition of type 'WINDOW' (aka 'struct _win_st')
241 | curscr->_cury = oy;
| ~~~~~~^
main.c:242:11: error: incomplete definition of type 'WINDOW' (aka 'struct _win_st')
242 | curscr->_curx = ox;
| ~~~~~~^
```
**Root Cause**:
The code attempts to access internal ncurses structure members (`curscr->_cury` and `curscr->_curx`) which are not part of the public API in modern ncurses libraries. These internal members are not accessible in both the system ncurses and Homebrew ncurses.
**Context**:
The problematic code is in the `tstp()` function (lines 241-242), which handles stop/start signals (SIGTSTP). After resuming from a stop signal, the code tries to manually restore the cursor position by directly accessing internal ncurses structure members.
**Affected Code**:
```c
void
tstp(int ignored)
{
// ... code ...
getyx(curscr, y, x);
mvcur(y, x, oy, ox);
fflush(stdout);
curscr->_cury = oy; // ERROR: Internal member access
curscr->_curx = ox; // ERROR: Internal member access
}
```
**Impact**:
- **Severity**: CRITICAL - Build fails completely
- The game cannot be compiled on modern systems with standard ncurses
- This affects all platforms using modern ncurses (not just macOS)
**Suggested Fix**:
1. Remove the direct structure member access (lines 241-242)
2. The `mvcur()` call on line 239 should be sufficient for cursor positioning
3. If cursor position tracking is needed, use public ncurses API functions like `getyx()` and `move()` instead
**Workaround Attempted**:
- Tried using Homebrew ncurses with explicit include/library paths as suggested in README troubleshooting section
- Same error occurred with both system ncurses and Homebrew ncurses
- Tried manual build using `Makefile.std` as documented in README (Alternative build method)
- Same error occurred with manual build method
- This confirms it's a code compatibility issue, not a configuration or build system issue
### 2. Compiler Warnings (Non-blocking)
**Location**: Multiple files
**Warning Messages**:
- `main.c`: Multiple warnings about deprecated function prototypes (C23 compatibility)
- Function declarations without prototypes in `extern.h` conflicting with definitions
**Impact**:
- **Severity**: LOW - Warnings only, do not prevent compilation
- Code compiles but may have issues with future C standards (C23)
**Note**: These warnings are expected for legacy C code and don't prevent the build from succeeding once the critical error is fixed.
## What Works
1.**Configure script**: Runs successfully
- Detects compiler, headers, and libraries correctly
- Creates `config.h` and `Makefile` properly
- Works with both system ncurses and Homebrew ncurses paths
2.**Prerequisites**: All required tools are available
- C compiler (gcc/clang)
- make
- ncurses library (via Homebrew)
3.**Build system**: Autotools configuration works correctly
- `configure` script exists and is executable
- No need for `autoreconf` (as documented in README)
## Recommendations for README Updates
1. **Add Known Compatibility Issue Section**:
- Document that the codebase has compatibility issues with modern ncurses
- Note that direct access to internal ncurses structures (`curscr->_cury`, `curscr->_curx`) will fail on modern systems
- Provide information about which ncurses versions are known to work (if any)
2. **Update macOS Troubleshooting**:
- The current macOS troubleshooting section mentions Homebrew ncurses paths, but this doesn't solve the compilation error
- Add a note that even with correct ncurses paths, the build may fail due to code compatibility issues
3. **Add Build Status**:
- Consider adding a "Known Issues" or "Build Status" section to the README
- Indicate that the codebase may need patches for modern ncurses compatibility
## Next Steps
1. **Code Fix Required**: The `main.c` file needs to be updated to remove direct access to internal ncurses structure members
2. **Testing**: After fix, verify the game builds and runs correctly
3. **Documentation**: Update README with any workarounds or fixes applied
## Additional Notes
- The README's Quick Start instructions are clear and easy to follow
- The build system (Autotools) is properly configured
- The issue is specifically with code compatibility, not with the build system or documentation
- This appears to be a known issue with porting old curses-based code to modern ncurses

20
Dockerfile.lint Normal file
View File

@@ -0,0 +1,20 @@
# Lint image, built by script/lint: golangci-lint runs as a build step, so
# a successful build is a clean lint.
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
# This stage must stay the one that runs golangci-lint, and its name must
# match $stage in script/lint. --target halts the build at this stage, so
# moving the lint step to another stage, or adding a stage after this one,
# is not caught.
FROM deps AS lint
COPY . .
RUN golangci-lint run --config .golangci.yml ./...

88
MEMORY.md Normal file
View File

@@ -0,0 +1,88 @@
# Project Memory
Working notes for agents on this repo. Read this alongside TODO.md (which holds
the step queue and workflow) before starting work.
## Error handling
Panicking on bad/unexpected errors is allowed and preferred over threading
unlikely error returns through game code — e.g. write-side Close/encode failures
where continuing would mean corrupt state. Return errors where a caller
genuinely handles them (save-file prompts, restore validation). Reserve
deliberate `_ =` discards for true best-effort paths (scorefile writes,
`Terminal.Interrupt`'s post to a full event queue), always with a comment saying
why.
Signal-time autosave used to be on that list and no longer is (issue #24). It is
best effort in the sense that nothing can be reported to a player whose terminal
is already going away, but the outcome is a value, not a discard: the signal
goroutine calls `AutoSaveOnSignal`, which hands the save to the game goroutine —
the only one allowed to touch game state — and returns whether it was taken
before the deadline. The game answers between turns (`command`), while parked
waiting for a key (`readchar`), and while parked in the `!` shell escape
(`runShellEscape`). `saveFile` writes a temporary file and renames it over the
target, so a save that fails or never happens leaves the player's previous save
whole; never reintroduce a `Remove` before the write in `autoSave`, and never
encode game state from any goroutine but the game's.
Do not upgrade that into "the snapshot is always taken between commands" — it is
not. What is true is that the encode runs on the state-owning goroutine, so the
snapshot is internally consistent and restorable. Only the check at the top of
`command` is a between-commands snapshot; the other two service points both sit
inside a `command` call already under way. `readchar` is reached from
mid-command prompts (`--More--`, `askOverwrite`, `getStr`, direction and pack
prompts) with the command's mutations already applied, and `runShellEscape` is
reached from `shell`, an ordinary `'!'` command handler, with that turn's
`DoDaemons(Before)`/`DoFuses(Before)` already fired and its AFTER pass not yet.
Restoring re-enters `playit` at the top of `command`, so either way the rest of
that command is lost and a fresh BEFORE pass runs on top of the one already in
the snapshot. That is acceptable and documented; two successive false claims —
first that `readchar` was safe, then that two of the three service points were
between-commands — were caught in review of PR #26, and neither may come back.
Related, and easy to reintroduce: work moved onto a helper goroutine must not be
allowed to panic there. A panic at the top of any goroutine kills the process
without running the other goroutines' defers, including `cmd/rogue/main.go`'s
`defer t.Fini()`, which is what leaves a raw tty (issue #12). `runShellEscape`
recovers its helper's panic and re-raises it on the game goroutine for exactly
that reason.
C's exit() calls are not unwound: one game run is one process, so myExit
(game/rip.go) restores the terminal via Terminal.Fini and calls os.Exit(0), and
Run() never returns. There is nothing to recover — do not write code that
expects to regain control after game-over. The testing consequence is that any
death (combat, starvation, level drain, freezing) exits the _test binary_, so
tests drive command() directly rather than Run(), and crash-sweep drives pin the
hero each turn with the fortify() helper in game/run_test.go.
## Linting
The .golangci.yml is byte-identical to the canonical shared config and must not
be edited — not even to add an exception. To disable a linter, ask sneak,
explaining what the linter does; approved exceptions are recorded as in-code
//nolint directives (file-level where a whole file is affected) carrying the
approval date, which is what keeps the config canonical. Approved so far:
testpackage, exhaustive, and mnd (2026-07-07). paralleltest was approved on
2026-07-06 but the exception is no longer in force — it was fixed instead, with
t.Parallel() in all 32 tests. The complexity linters (cyclop, gocognit, nestif)
are enabled and clean as of refactor step 7 (2026-07-07): the whole
golangci-lint run is 0 issues, so keep it that way — decompose new hot spots
rather than reaching for a nolint. Line-level //nolint with a reason is used
sparingly for C-faithfulness (e.g. the authentic "missle" message spellings) and
provably-safe gosec conversions; each needs a justifying comment.
## Faithfulness
Behavior must not change during the idiomatic-Go refactor unless a TODO step
says so. The 80x24 seed-compatible gameplay, message text (including original
typos), RNG call order, and C quirks (documented in tests like
TestHoldScrollGreedyMonsterQuirk) are contract. Doc comments keep their "(file.c
func_name)" breadcrumbs.
## Debugging
Write real, committed test files with t.Logf output and run them with the make
targets — `make test` (or `make check` for the full gate); never raw `go test`.
The target carries `-timeout 30s -race -cover` and reruns verbosely on failure,
so a raw invocation silently drops the race detector. No throwaway scratch
scripts. Successful debug probes become regression tests.

70
Makefile Normal file
View File

@@ -0,0 +1,70 @@
# Development convenience targets. This repo is exempt from the standard
# policy scaffold (no CI config, no REPO_POLICIES.md, no application
# Dockerfile) except for the lint container: per sneak's 2026-08-09
# ruling, linting runs in docker only, so Dockerfile.lint and script/lint
# are part of this repo. This Makefile is otherwise only a thin wrapper
# around the Go toolchain and prettier so `make fmt` / `make check` behave
# the same as in sneak's other repos.
GO_PKGS := ./...
MD_FILES := $(shell git ls-files '*.md')
PRETTIER := prettier --tab-width 4 --prose-wrap always
# Every generated artifact goes here, and the whole directory is
# git-ignored. Targets that write outside it can commit their output.
BUILD_DIR := build
BIN := $(BUILD_DIR)/rogue
COVERPROF := $(BUILD_DIR)/coverage.out
COVERHTML := $(BUILD_DIR)/coverage.html
.PHONY: build check cover cover-html fmt fmt-check lint test
# Format, lint, and test — the full local pre-commit gate. Keep this list
# to targets that write nothing into the working tree.
check: fmt-check lint test
# Build the executable into $(BUILD_DIR). `go build -o` does not create the
# parent directory.
build:
@mkdir -p $(BUILD_DIR)
go build -o $(BIN) ./cmd/rogue
# Per-function coverage, for finding which functions are untested. The
# percentage `make test` prints is a per-package total and cannot answer
# that. Writes files, so it stays out of `check`.
cover:
@mkdir -p $(BUILD_DIR)
go test -timeout 30s -coverprofile=$(COVERPROF) $(GO_PKGS)
go tool cover -func=$(COVERPROF)
# Render the same profile as annotated source.
cover-html: cover
go tool cover -html=$(COVERPROF) -o $(COVERHTML)
@echo "wrote $(COVERHTML)"
# Format Go and Markdown in place.
fmt:
gofmt -w .
$(PRETTIER) --write $(MD_FILES)
# Fail if any Go or Markdown file is not formatted.
fmt-check:
@unformatted="$$(gofmt -l .)"; \
if [ -n "$$unformatted" ]; then \
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; \
fi
$(PRETTIER) --check $(MD_FILES)
# Run the house linter. golangci-lint is never installed on the host: the
# work happens inside the pinned container built by Dockerfile.lint, and
# this target is a thin shim over the script that builds it.
lint:
./script/lint
# Run the test suite. Quiet on success; on failure, rerun verbosely for the
# full output and still fail the target (the first run already proved the
# tests are broken, so a flaky pass on the rerun must not rescue the build).
test:
@go test -timeout 30s -race -cover $(GO_PKGS) || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v $(GO_PKGS); exit 1; }

View File

@@ -1,220 +0,0 @@
###############################################################################
#
# Makefile for rogue
#
# Rogue: Exploring the Dungeons of Doom
# Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
# All rights reserved.
#
# See the file LICENSE.TXT for full copyright and licensing information.
#
###############################################################################
###############################################################################
# Site configuration occurs beneath this comment
# Typically ./configure (autoconf tools) configures this section
# This section could be manually configured if autoconf/configure fails
###############################################################################
DISTNAME=@PACKAGE_TARNAME@@PACKAGE_VERSION@
PACKAGE_TARNAME = @PACKAGE_TARNAME@-@PACKAGE_VERSION@
PROGRAM=@PROGRAM@
O=o
#CC=gcc
CC = @CC@
#CFLAGS=-O2
CFLAGS= @CFLAGS@
#LIBS=-lcurses
LIBS = @LIBS@
#RM=rm -f
RM = rm -f
#GROFF=groff
GROFF = @GROFF@
#NROFF=nroff
NROFF = @NROFF@
#TBL=tbl
TBL = @TBL@
#COLCRT=colcrt
COLCRT = @COLCRT@
#SED=sed
SED = @SED@
#SCOREFILE=rogue54.scr
SCOREFILE = @SCOREFILE@
#LOCKFILE=rogue54.lck
LOCKFILE = @LOCKFILE@
#GROUPOWNER=games
GROUPOWNER = @GROUPOWNER@
#CPPFLAGS=-DHAVE_CONFIG_H
CPPFLAGS =@DEFS@ @CPPFLAGS@
#DISTFILE = $(PROGRAM)
DISTFILE = $(DISTNAME)-@TARGET@
INSTALL=./install-sh
#INSTGROUP=-g games
INSTGROUP=
#INSTOWNER=-u root
INSTOWNER=
CHGRP=chgrp
MKDIR=mkdir
TOUCH=touch
RMDIR=rmdir
CHMOD=chmod
DESTDIR=
prefix=@prefix@
exec_prefix=@exec_prefix@
datarootdir=@datarootdir@
datadir=@datadir@
bindir=@bindir@
mandir=@mandir@
docdir=@docdir@
man6dir = $(mandir)/man6
###############################################################################
# Site configuration occurs above this comment
# It should not be necessary to change anything below this comment
###############################################################################
HDRS = rogue.h extern.h score.h
OBJS1 = vers.$(O) extern.$(O) armor.$(O) chase.$(O) command.$(O) \
daemon.$(O) daemons.$(O) fight.$(O) init.$(O) io.$(O) list.$(O) \
mach_dep.$(O) main.$(O) mdport.$(O) misc.$(O) monsters.$(O) \
move.$(O) new_level.$(O)
OBJS2 = options.$(O) pack.$(O) passages.$(O) potions.$(O) rings.$(O) \
rip.$(O) rooms.$(O) save.$(O) scrolls.$(O) state.$(O) sticks.$(O) \
things.$(O) weapons.$(O) wizard.$(O) xcrypt.$(O)
OBJS = $(OBJS1) $(OBJS2)
CFILES = vers.c extern.c armor.c chase.c command.c daemon.c \
daemons.c fight.c init.c io.c list.c mach_dep.c \
main.c mdport.c misc.c monsters.c move.c new_level.c \
options.c pack.c passages.c potions.c rings.c rip.c \
rooms.c save.c scrolls.c state.c sticks.c things.c \
weapons.c wizard.c xcrypt.c
MISC_C = findpw.c scedit.c scmisc.c
DOCSRC = rogue.me.in rogue.6.in rogue.doc.in rogue.html.in rogue.cat.in
DOCS = $(PROGRAM).doc $(PROGRAM).html $(PROGRAM).cat $(PROGRAM).me \
$(PROGRAM).6
AFILES = configure Makefile.in configure.ac config.h.in config.sub config.guess \
install-sh rogue.6.in rogue.me.in rogue.html.in rogue.doc.in rogue.cat.in
MISC = Makefile.std LICENSE.TXT rogue54.sln rogue54.vcproj rogue.spec \
rogue.png rogue.desktop
.SUFFIXES: .obj
.c.obj:
$(CC) $(CFLAGS) $(CPPFLAGS) /c $*.c
.c.o:
$(CC) $(CFLAGS) $(CPPFLAGS) -c $*.c
$(PROGRAM): $(HDRS) $(OBJS)
$(CC) $(CFLAGS) $(LDFLAGS) $(OBJS) $(LIBS) -o $@
clean:
$(RM) $(OBJS1)
$(RM) $(OBJS2)
$(RM) core a.exe a.out a.exe.stackdump $(PROGRAM) $(PROGRAM).exe
$(RM) $(PROGRAM).tar $(PROGRAM).tar.gz $(PROGRAM).zip
$(RM) $(DISTNAME)/*
-rmdir $(DISTNAME)
maintainer-clean:
$(RM) config.h
$(RM) Makefile
$(RM) config.status
$(RM) -r autom4te.cache
$(RM) config.log
$(RM) $(PROGRAM).scr $(PROGRAM).lck
stddocs:
sed -e 's/@PROGRAM@/rogue/' -e 's/@SCOREFILE@/rogue.scr/' rogue.6.in > rogue.6
sed -e 's/@PROGRAM@/rogue/' -e 's/@SCOREFILE@/rogue.scr/' rogue.me.in > rogue.me
sed -e 's/@PROGRAM@/rogue/' -e 's/@SCOREFILE@/rogue.scr/' rogue.html.in > rogue,html
sed -e 's/@PROGRAM@/rogue/' -e 's/@SCOREFILE@/rogue.scr/' rogue.doc.in > rogue.doc
sed -e 's/@PROGRAM@/rogue/' -e 's/@SCOREFILE@/rogue.scr/' rogue.cat.in > rogue.cat
dist.src:
$(MAKE) $(MAKEFILE) clean
mkdir $(DISTNAME)
cp $(CFILES) $(HDRS) $(MISC) $(AFILES) $(DISTNAME)
tar cf $(DISTNAME)-src.tar $(DISTNAME)
gzip -f $(DISTNAME)-src.tar
rm -fr $(DISTNAME)
findpw: findpw.c xcrypt.o mdport.o xcrypt.o
$(CC) -s -o findpw findpw.c xcrypt.o mdport.o -lcurses
scedit: scedit.o scmisc.o vers.o mdport.o xcrypt.o
$(CC) -s -o scedit vers.o scedit.o scmisc.o mdport.o xcrypt.o -lcurses
scmisc.o scedit.o:
$(CC) -O -c $(SF) $*.c
$(PROGRAM).doc: rogue.me
if test "x$(GROFF)" != "x" -a "x$(SED)" != "x" ; then \
$(GROFF) -P-c -t -me -Tascii rogue.me | $(SED) -e 's/.\x08//g' > $(PROGRAM).doc ;\
elif test "x$(NROFF)" != "x" -a "x$(TBL)" != "x" -a "x$(COLCRT)" != "x" ; then \
tbl rogue.me | $(NROFF) -me | colcrt - > $(PROGRAM).doc ;\
fi
$(PROGRAM).cat: rogue.6
if test "x$(GROFF)" != "x" -a "x$(SED)" != "x" ; then \
$(GROFF) -Tascii -man rogue.6 | $(SED) -e 's/.\x08//g' > $(PROGRAM).cat ;\
elif test "x$(NROFF)" != "x" -a "x$(TBL)" != "x" -a "x$(COLCRT)" != "x" ; then \
$(NROFF) -man rogue.6 | $(COLCRT) - > $(PROGRAM).cat ;\
fi
dist: clean $(PROGRAM)
tar cf $(DISTFILE).tar $(PROGRAM) LICENSE.TXT $(DOCS)
gzip -f $(DISTFILE).tar
install: $(PROGRAM)
-$(TOUCH) test
-if test ! -f $(DESTDIR)$(SCOREFILE) ; then $(INSTALL) -m 0664 test $(DESTDIR)$(SCOREFILE) ; fi
-$(INSTALL) -m 0755 $(PROGRAM) $(DESTDIR)$(bindir)/$(PROGRAM)
-if test "x$(GROUPOWNER)" != "x" ; then \
$(CHGRP) $(GROUPOWNER) $(DESTDIR)$(SCOREFILE) ; \
$(CHGRP) $(GROUPOWNER) $(DESTDIR)$(bindir)/$(PROGRAM) ; \
$(CHMOD) 02755 $(DESTDIR)$(bindir)/$(PROGRAM) ; \
$(CHMOD) 0464 $(DESTDIR)$(SCOREFILE) ; \
fi
-if test -d $(man6dir) ; then $(INSTALL) -m 0644 rogue.6 $(DESTDIR)$(man6dir)/$(PROGRAM).6 ; fi
-if test ! -d $(man6dir) ; then $(INSTALL) -m 0644 rogue.6 $(DESTDIR)$(mandir)/$(PROGRAM).6 ; fi
-$(INSTALL) -m 0644 rogue.doc $(DESTDIR)$(docdir)/$(PROGRAM).doc
-$(INSTALL) -m 0644 rogue.html $(DESTDIR)$(docdir)/$(PROGRAM).html
-$(INSTALL) -m 0644 rogue.cat $(DESTDIR)$(docdir)/$(PROGRAM).cat
-$(INSTALL) -m 0644 LICENSE.TXT $(DESTDIR)$(docdir)/LICENSE.TXT
-$(INSTALL) -m 0644 rogue.me $(DESTDIR)$(docdir)/$(PROGRAM).me
-if test ! -f $(DESTDIR)$(LOCKFILE) ; then $(INSTALL) -m 0666 test $(DESTDIR)$(LOCKFILE) ; $(RM) $(DESTDIR)$(LOCKFILE) ; fi
-$(RM) test
uninstall:
-$(RM) $(DESTDIR)$(bindir)/$(PROGRAM)
-$(RM) $(DESTDIR)$(man6dir)/$(PROGRAM).6
-$(RM) $(DESTDIR)$(docdir)$(PROGRAM)/$(PROGRAM).doc
-$(RM) $(DESTDIR)$(LOCKFILE)
-$(RMDIR) $(DESTDIR)$(docdir)$(PROGRAM)
reinstall: uninstall install

View File

@@ -1,158 +0,0 @@
#
# Makefile for rogue
# @(#)Makefile 4.21 (Berkeley) 02/04/99
#
# Rogue: Exploring the Dungeons of Doom
# Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
# All rights reserved.
#
# See the file LICENSE.TXT for full copyright and licensing information.
#
DISTNAME = rogue5.4.4
PROGRAM = rogue54
O = o
HDRS = rogue.h extern.h score.h
OBJS1 = vers.$(O) extern.$(O) armor.$(O) chase.$(O) command.$(O) \
daemon.$(O) daemons.$(O) fight.$(O) init.$(O) io.$(O) list.$(O) \
mach_dep.$(O) main.$(O) mdport.$(O) misc.$(O) monsters.$(O) \
move.$(O) new_level.$(O)
OBJS2 = options.$(O) pack.$(O) passages.$(O) potions.$(O) rings.$(O) \
rip.$(O) rooms.$(O) save.$(O) scrolls.$(O) state.$(O) sticks.$(O) \
things.$(O) weapons.$(O) wizard.$(O) xcrypt.$(O)
OBJS = $(OBJS1) $(OBJS2)
CFILES = vers.c extern.c armor.c chase.c command.c daemon.c \
daemons.c fight.c init.c io.c list.c mach_dep.c \
main.c mdport.c misc.c monsters.c move.c new_level.c \
options.c pack.c passages.c potions.c rings.c rip.c \
rooms.c save.c scrolls.c state.c sticks.c things.c \
weapons.c wizard.c xcrypt.c
MISC_C = findpw.c scedit.c scmisc.c
DOCSRC = rogue.me.in rogue.6.in rogue.doc.in rogue.html.in rogue.cat.in
DOCS = $(PROGRAM).doc $(PROGRAM).html $(PROGRAM).cat $(PROGRAM).me \
$(PROGRAM).6
AFILES = configure Makefile.in configure.ac config.h.in config.sub config.guess \
install-sh rogue.6.in rogue.me.in rogue.html.in rogue.doc.in rogue.cat.in
MISC = Makefile.std LICENSE.TXT rogue54.sln rogue54.vcproj rogue.spec \
rogue.png rogue.desktop
CC = gcc
FEATURES = -DALLSCORES -DSCOREFILE=\"$(SCOREFILE)\" -DLOCKFILE=\"$(LOCKFILE)\"
CPPFLAGS =
CFLAGS = -O3
LDFLAGS =
LIBS = -lcurses
RM = rm -f
MAKEFILE = -f Makefile.std
SCOREFILE= $(PROGRAM).scr
LOCKFILE = $(PROGRAM).lck
OUTFLAG = -o
EXE =
.SUFFIXES: .obj
.c.obj:
$(CC) $(CFLAGS) $(CPPFLAGS) $(FEATURES) /c $*.c
.c.o:
$(CC) $(CFLAGS) $(CPPFLAGS) $(FEATURES) -c $*.c
$(PROGRAM): $(HDRS) $(OBJS) fixdocs
$(CC) $(LDFLAGS) $(OBJS) $(LIBS) $(OUTFLAG)$@$(EXE)
clean:
$(RM) $(OBJS1)
$(RM) $(OBJS2)
$(RM) core a.exe a.out a.exe.stackdump $(PROGRAM) $(PROGRAM).exe $(PROGRAM).lck
$(RM) $(PROGRAM).tar $(PROGRAM).tar.gz $(PROGRAM).zip
$(RM) $(DISTNAME)/*
dist.src:
$(MAKE) $(MAKEFILE) clean
mkdir $(DISTNAME)
cp $(CFILES) $(HDRS) $(MISC) $(AFILES) $(DISTNAME)
tar cf $(DISTNAME)-src.tar $(DISTNAME)
gzip -f $(DISTNAME)-src.tar
rm -fr $(DISTNAME)
findpw: findpw.c xcrypt.o mdport.o xcrypt.o
$(CC) -s -o findpw findpw.c xcrypt.o mdport.o -lcurses
scedit: scedit.o scmisc.o vers.o mdport.o xcrypt.o
$(CC) -s -o scedit vers.o scedit.o scmisc.o mdport.o xcrypt.o -lcurses
scmisc.o scedit.o:
$(CC) -O -c $(SF) $*.c
doc.nroff:
tbl rogue.me | nroff -me | colcrt - > rogue.doc
nroff -man rogue.6 | colcrt - > rogue.cat
doc.groff:
groff -P-c -t -me -Tascii rogue.me | sed -e 's/.\x08//g' > rogue.doc
groff -man rogue.6 | sed -e 's/.\x08//g' > rogue.cat
fixdocs:
sed -e 's/@PROGRAM@/$(PROGRAM)/' -e 's/@SCOREFILE@/$(SCOREFILE)/' rogue.6.in > $(PROGRAM).6
sed -e 's/@PROGRAM@/$(PROGRAM)/' -e 's/@SCOREFILE@/$(SCOREFILE)/' rogue.me.in > $(PROGRAM).me
sed -e 's/@PROGRAM@/$(PROGRAM)/' -e 's/@SCOREFILE@/$(SCOREFILE)/' rogue.html.in > $(PROGRAM).html
sed -e 's/@PROGRAM@/$(PROGRAM)/' -e 's/@SCOREFILE@/$(SCOREFILE)/' rogue.doc.in > $(PROGRAM).doc
sed -e 's/@PROGRAM@/$(PROGRAM)/' -e 's/@SCOREFILE@/$(SCOREFILE)/' rogue.cat.in > $(PROGRAM).cat
dist.irix:
$(MAKE) $(MAKEFILE) clean
$(MAKE) $(MAKEFILE) CC=cc $(PROGRAM)
tar cf $(DISTNAME)-irix.tar $(PROGRAM) LICENSE.TXT $(DOCS)
gzip -f $(DISTNAME)-irix.tar
dist.aix:
$(MAKE) $(MAKEFILE) clean
$(MAKE) $(MAKEFILE) CC=xlc CFLAGS="-qmaxmem=16768 -O3 -qstrict" $(PROGRAM)
tar cf $(DISTNAME)-aix.tar $(PROGRAM) LICENSE.TXT $(DOCS)
gzip -f $(DISTNAME)-aix.tar
dist.linux:
$(MAKE) $(MAKEFILE) clean
$(MAKE) $(MAKEFILE) $(PROGRAM)
tar cf $(DISTNAME)-linux.tar $(PROGRAM) LICENSE.TXT $(DOCS)
gzip -f $(DISTNAME)-linux.tar
dist.interix:
@$(MAKE) $(MAKEFILE) clean
@$(MAKE) $(MAKEFILE) CFLAGS="-ansi" $(PROGRAM)
tar cf $(DISTNAME)-interix.tar $(PROGRAM) LICENSE.TXT $(DOCS)
gzip -f $(DISTNAME)-interix.tar
dist.cygwin:
@$(MAKE) $(MAKEFILE) --no-print-directory clean
@$(MAKE) $(MAKEFILE) CPPFLAGS="-I/usr/include/ncurses" --no-print-directory $(PROGRAM)
tar cf $(DISTNAME)-cygwin.tar $(PROGRAM).exe LICENSE.TXT $(DOCS)
gzip -f $(DISTNAME)-cygwin.tar
#
# Use MINGW32-MAKE to build this target
#
dist.mingw32:
@$(MAKE) $(MAKEFILE) --no-print-directory RM="cmd /c del" clean
@$(MAKE) $(MAKEFILE) --no-print-directory CPPFLAGS="-I../pdcurses" LIBS="../pdcurses/pdcurses.a" $(PROGRAM)
cmd /c del $(DISTNAME)-mingw32.zip
zip $(DISTNAME)-mingw32.zip $(PROGRAM).exe LICENSE.TXT $(DOCS)
dist.djgpp:
@$(MAKE) $(MAKEFILE) --no-print-directory clean
@$(MAKE) $(MAKEFILE) --no-print-directory LDFLAGS="-L$(DJDIR)/LIB" \
LIBS="-lpdcurses" $(PROGRAM)
rm -f $(DISTNAME)-djgpp.zip
zip $(DISTNAME)-djgpp.zip $(PROGRAM) LICENSE.TXT $(DOCS)
#
# Use NMAKE to build this targer
#
dist.win32:
@$(MAKE) $(MAKEFILE) /NOLOGO O="obj" RM="-del" clean
@$(MAKE) $(MAKEFILE) /NOLOGO O="obj" CC="CL" \
LIBS="..\pdcurses\pdcurses.lib shell32.lib user32.lib Advapi32.lib" \
EXE=".exe" OUTFLAG="/Fe" CPPFLAGS="-I..\pdcurses" \
CFLAGS="-nologo -Ox -wd4033 -wd4716" $(PROGRAM)
-del $(DISTNAME)-win32.zip
zip $(DISTNAME)-win32.zip $(PROGRAM).exe LICENSE.TXT $(DOCS)

713
README.md
View File

@@ -1,685 +1,94 @@
# Rogue: Exploring the Dungeons of Doom
# Rogue: Exploring the Dungeons of Doom (Go port)
[![License](https://img.shields.io/badge/license-BSD-blue.svg)](LICENSE.TXT)
**Rogue** is the original graphical dungeon-crawling adventure game that spawned an entire genre. This is version 5.4.4, a classic roguelike where you explore procedurally generated dungeons, fight monsters, collect treasure, and attempt to retrieve the Amulet of Yendor.
**Rogue** is the original dungeon-crawling adventure game that spawned an entire
genre. This branch is a faithful Go port of Rogue 5.4.4: explore procedurally
generated dungeons, fight monsters, collect treasure, and attempt to retrieve
the Amulet of Yendor.
**Original Authors:** Michael Toy, Ken Arnold, and Glenn Wichman (1980-1983, 1985, 1999)
**Original authors:** Michael Toy, Ken Arnold, and Glenn Wichman (19801983,
1985, 1999).
---
The port is function-by-function faithful to the classic C sources — same
dungeon generation (seed-compatible RNG), same combat math, same item tables,
same messages. The C reference implementation lives on the `master` and
`modern-rogue` branches; [ARCHITECTURE.md](ARCHITECTURE.md) documents both the
original program structure and the design of this port.
## Table of Contents
## Building and running
- [Quick Start](#quick-start)
- [Prerequisites](#prerequisites)
- [Building from Source](#building-from-source)
- [Running the Game](#running-the-game)
- [Project Structure](#project-structure)
- [Development Guide](#development-guide)
- [Contributing](#contributing)
- [Troubleshooting](#troubleshooting)
- [Resources](#resources)
---
## Quick Start
Requires Go 1.25 or later and a terminal at least 80x24.
```bash
# Configure and build
./configure
make
# Run the game
./rogue
make build
./build/rogue
```
**Note**: The executable name defaults to `rogue`, but may be different if configured with `--with-program-name`. Check the output of `make` to see the actual executable name.
**Note**: If you encounter compilation errors, especially related to ncurses compatibility, see [BUILD_ISSUES.md](BUILD_ISSUES.md) for known issues and workarounds.
---
## Prerequisites
### Required
- **C Compiler**: GCC or Clang (C89/C90 compatible)
- **make**: Build automation tool (usually pre-installed)
- **Linux**: Usually pre-installed, or `sudo apt-get install build-essential` (Debian/Ubuntu)
- **macOS**: Included with Xcode Command Line Tools (`xcode-select --install`)
- **FreeBSD**: `pkg install gmake` (or use `gmake` instead of `make`)
- **ncurses library**: For terminal-based graphics
- **Linux**: `sudo apt-get install libncurses5-dev` (Debian/Ubuntu)
- **Linux**: `sudo yum install ncurses-devel` (RHEL/CentOS/Fedora)
- **macOS**: `brew install ncurses` (Homebrew)
- **FreeBSD**: `pkg install ncurses`
### Optional (for building from source)
- **Autotools**: autoconf, automake, m4 (needed if `configure` script doesn't exist)
- **Linux**: `sudo apt-get install autoconf automake m4` (Debian/Ubuntu)
- **Linux**: `sudo yum install autoconf automake m4` (RHEL/CentOS/Fedora)
- **macOS**: Usually pre-installed with Xcode Command Line Tools, or `brew install autoconf automake`
- **FreeBSD**: `pkg install autoconf automake m4`
- **Note**: If the `configure` script already exists in the repository, you don't need these tools
### Optional (for building documentation)
- **Documentation tools**: groff/nroff, tbl, colcrt, sed (for generating man pages and docs)
- **Linux**: Usually pre-installed, or `sudo apt-get install groff` (Debian/Ubuntu)
- **macOS**: Usually pre-installed
- **Note**: Documentation can be built later with `make` if these tools are available
- **Note**: Generated documentation (man pages, HTML, etc.) is adapted to match the actual codebase outcomes and build configuration (e.g., executable name, scoreboard file location)
### Platform Support
This codebase supports multiple platforms:
- **Unix-like systems** (Linux, macOS, FreeBSD, etc.)
- **Windows** (via Visual Studio project files or MinGW/MSYS2)
- **DOS** (DJGPP)
- **Cygwin**
---
## Building from Source
### Standard Build (Recommended)
The project uses Autotools for configuration. The build process depends on whether the `configure` script already exists:
**If `configure` script exists** (most common case):
```bash
# Configure the build system
./configure
# Compile
make
# Optional: Install system-wide (requires root)
sudo make install
```
**If `configure` script does NOT exist** (e.g., fresh git clone without generated files):
```bash
# Generate configure script (requires autoconf, automake, m4)
autoreconf -fiv
# Configure the build system
./configure
# Compile
make
# Optional: Install system-wide (requires root)
sudo make install
```
**Note**: Most source distributions include the `configure` script, so you typically only need `autoreconf` if you're building directly from a git repository that doesn't include generated files.
**Note**: The executable name and other build outputs are determined by the `configure` script based on the codebase configuration. If you're using `Makefile.std` directly (manual build), the default executable name is `rogue54`, whereas `configure` defaults to `rogue`. Always check the actual output of your build process to confirm the executable name.
### Configure Options
The `configure` script supports several options:
```bash
# Enable wizard mode (debug/cheat mode)
./configure --enable-wizardmode
# Set custom scoreboard file location
./configure --enable-scorefile=/path/to/scoreboard.scr
# Disable scoreboard
./configure --enable-scorefile=no
# Set custom program name
./configure --with-program-name=myrogue
# Install as setgid (for shared scoreboard)
./configure --enable-setgid=games
# See all options
./configure --help
```
### Manual Build (Without Autotools)
If you prefer to build manually or the configure script fails:
**Option 1: Using Makefile.std**
```bash
# Build using the standard Makefile
make -f Makefile.std
# Note: This creates 'rogue54' by default (see Makefile.std to change)
```
**Option 2: Direct compilation**
```bash
# Compile directly (may need additional defines)
gcc -O2 -o rogue *.c -lcurses
# Or with more defines (see Makefile.std for full list):
gcc -O2 -DALLSCORES -DSCOREFILE=\"rogue.scr\" -DLOCKFILE=\"rogue.lck\" -o rogue *.c -lcurses
```
**Note**:
- Manual builds may require additional defines. See `Makefile.std` for reference.
- The executable name may differ (`rogue` vs `rogue54` depending on build method).
- You may need to adjust include paths if ncurses is in a non-standard location.
### Windows Build
#### Using Visual Studio
1. **Install PDCurses**:
- Download PDCurses from https://pdcurses.org/
- Extract to a directory (e.g., `C:\pdcurses`)
- Build PDCurses library following its instructions
2. **Configure Visual Studio project**:
- Open `rogue54.sln` in Visual Studio
- Update include/library paths in project settings to point to your PDCurses installation
- Ensure PDCurses library is linked (check project properties → Linker → Input)
3. **Build the solution**: Build → Build Solution (or press F7)
**Alternative**: If PDCurses is in a peer directory (`../pdcurses/`), the project may work without modification.
#### Using MinGW/MSYS2
```bash
# Install ncurses via MSYS2
pacman -S mingw-w64-x86_64-ncurses
# Configure and build
./configure
make
```
**Note**: For MinGW/MSYS2, you may need to use `mingw32-make` instead of `make` depending on your installation.
---
## Running the Game
### Basic Usage
```bash
# Start a new game
./rogue
# Restore from a saved game
./rogue ~/rogue.save
# Restore a saved game
./build/rogue ~/rogue.save
# View high scores
./rogue -s
./build/rogue -s
# Test death screen (demo mode)
./rogue -d
# Test the death screen (demo mode)
./build/rogue -d
```
### In-Game Commands
## In-game commands
- **`?`** - Show help/command list
- **`/`** - Identify objects on screen
- **Arrow keys** or **h/j/k/l** - Move
- **`.`** - Wait/rest
- **`i`** - Show inventory
- **`e`** - Eat food
- **`q`** - Quaff potion
- **`r`** - Read scroll
- **`w`** - Wield weapon
- **`W`** - Wear armor
- **`t`** - Throw object
- **`z`** - Zap wand/staff
- **`Q`** - Quit game
Press `?` in game for the full list.
### Environment Variables
- **arrows** or **h/j/k/l/y/u/b/n** — move (shift to run, ctrl to run until
adjacent)
- **`.`** rest, **`s`** search for hidden doors and traps
- **`i`** inventory, **`,`** pick up, **`d`** drop
- **`q`** quaff potion, **`r`** read scroll, **`e`** eat food
- **`w`** wield weapon, **`W`** wear armor, **`P`**/**`R`** put on / remove ring
- **`t`** throw, **`z`** zap a wand, **`f`**/**`F`** fight
- **`>`**/**`<`** take the stairs
- **`S`** save, **`Q`** quit
## Environment
```bash
# Set game options via environment
export ROGUEOPTS="name=YourName,terse,jump"
# Game options, as in the original
export ROGUEOPTS="name=YourName,terse,jump,fruit=mango"
# Wizard mode: set dungeon seed
export SEED=12345
./rogue "" # Empty string as first arg enables wizard mode
# Wizard (debug) mode, with a reproducible dungeon
ROGUE_WIZARD=1 SEED=12345 ./build/rogue
```
---
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob snapshots
and, as in the original, are deleted when restored.
## Project Structure
### Core Files
## Code layout
```
rogue.h # Main header with data structures and defines
extern.h # External declarations and platform defines
main.c # Entry point, initialization, main game loop
command.c # Command processing and input handling
game/ the game engine: one Go file per original C file,
function-by-function (see ARCHITECTURE.md for the mapping)
term/ tcell-backed terminal, replacing curses
cmd/rogue/ the executable
```
### Game Systems
**Note**: All source files are in the root directory. The structure below is logical grouping, not actual directory structure.
**Combat System**:
```
fight.c # Combat mechanics
weapons.c # Weapon types and properties
armor.c # Armor types and properties
```
**Monster System**:
```
monsters.c # Monster definitions and stats
chase.c # Monster AI and pathfinding
```
**Item System**:
```
potions.c # Potion types and effects
scrolls.c # Scroll types and effects
rings.c # Ring types and effects
sticks.c # Wand/staff types and effects
things.c # General item handling
```
**Dungeon Generation**:
```
rooms.c # Room generation
passages.c # Corridor generation
new_level.c # Level creation and initialization
```
**User Interface**:
```
io.c # Input/output handling
list.c # Inventory and object lists
rip.c # Death screen
```
### Supporting Systems
```
daemon.c # Background processes (monster movement, hunger, etc.)
daemons.c # Daemon management
move.c # Player and monster movement
pack.c # Inventory management
save.c # Save/load game state
state.c # Game state management
init.c # Initialization routines
extern.c # Global variable definitions
```
### Platform Abstraction
```
mach_dep.c # Machine-dependent code detection
mdport.c # Platform abstraction layer
```
### Build System
```
configure.ac # Autoconf configuration
Makefile.in # Makefile template
config.h.in # Config header template
```
---
## Development Guide
### Code Style
This codebase follows classic C (pre-C99) conventions:
- Uses `register` keyword for frequently accessed variables
- Custom macros for control flow (`when`, `otherwise`, `on()`, etc.)
- Linked lists for dynamic data structures
- Bit flags for object/monster states
- Function declarations in headers, definitions in `.c` files
### Key Data Structures
#### `THING` (union)
Represents both monsters and objects:
```c
union thing {
struct { // Monster/player
coord t_pos;
struct stats t_stats;
short t_flags;
// ...
} _t;
struct { // Object
int o_type;
int o_which;
int o_hplus, o_dplus;
// ...
} _o;
};
```
#### `PLACE`
Represents a map cell:
```c
typedef struct {
char p_ch; // Character to display
char p_flags; // Flags (seen, passage, etc.)
THING *p_monst; // Monster at this location
} PLACE;
```
### Game Loop Flow
```
main()
├─> Initialize (curses, player, objects)
├─> new_level() # Generate first level
├─> Start daemons # Background processes
└─> playit()
└─> while(playing)
└─> command()
├─> do_daemons(BEFORE)
├─> Read input
├─> Execute command
├─> do_daemons(AFTER)
└─> Monster movement
```
### Daemons and Fuses
**Daemons** are background processes that run every turn:
- `runners()` - Monster movement
- `doctor()` - Health regeneration
- `stomach()` - Hunger system
- `swander()` - Wandering monsters
**Fuses** are one-time delayed actions:
- Used for temporary effects (haste, confusion, etc.)
### Adding New Features
1. **New Monster Type**:
- Add entry to `monsters[]` array in `monsters.c`
- Update monster generation logic in `new_level.c`
2. **New Item Type**:
- Add type constant to `rogue.h`
- Add info structure (e.g., `pot_info[]` for potions)
- Implement effect in corresponding file (e.g., `potions.c`)
3. **New Command**:
- Add case in `command()` function in `command.c`
- Implement handler function
### Debugging
#### Enable Wizard Mode
```bash
./configure --enable-wizardmode
make
./rogue "" # Empty string enables wizard password prompt
```
Wizard mode provides:
- See all monsters (`SEEMONST` flag)
- Set dungeon seed via `SEED` environment variable
- Debug commands (see `wizard.c`)
#### Compile with Debug Symbols
```bash
./configure CFLAGS="-g -O0"
make
gdb ./rogue
```
### Testing
```bash
# Test death screen
./rogue -d
# Test scoreboard
./rogue -s
# Test save/restore
./rogue
# Play a bit, save (S command), quit
./rogue ~/rogue.save # Should restore
```
---
## Contributing
We welcome contributions! Here's how to get started:
### Getting Started
1. **Fork the repository** (if using git)
2. **Create a branch** for your changes
3. **Make your changes** following the code style
4. **Test thoroughly** - play the game, test edge cases
5. **Submit a pull request** or patch
### Contribution Guidelines
- **Code Style**: Follow existing conventions (see [Development Guide](#development-guide))
- **Testing**: Test on multiple platforms if possible
- **Documentation**: Update relevant comments/docs
- **Commits**: Write clear commit messages
- **Scope**: Keep changes focused and atomic
### Areas Needing Help
- **Bug fixes**: Check for known issues or test edge cases
- **Platform support**: Improve Windows/DOS/Cygwin compatibility
- **Code cleanup**: Modernize while maintaining compatibility
- **Documentation**: Improve code comments and user docs
- **Performance**: Optimize hot paths
- **Accessibility**: Improve terminal compatibility
### Reporting Bugs
When reporting bugs, please include:
- Platform and OS version
- Compiler and version
- Steps to reproduce
- Expected vs. actual behavior
- Any error messages
---
## Troubleshooting
**Note**: For detailed information about known build issues, especially on modern systems, see [BUILD_ISSUES.md](BUILD_ISSUES.md).
### Build Issues
**Problem**: `configure: error: curses library not found`
**Solution**: Install ncurses development package:
```bash
# Debian/Ubuntu
sudo apt-get install libncurses5-dev
# RHEL/CentOS/Fedora
sudo yum install ncurses-devel
# macOS
brew install ncurses
```
**Problem**: `autoreconf: command not found` or `autoconf: command not found`
**Solution**: Install autotools (only needed if `configure` script doesn't exist):
```bash
# Debian/Ubuntu
sudo apt-get install autoconf automake m4
# RHEL/CentOS/Fedora
sudo yum install autoconf automake m4
# macOS (usually pre-installed with Xcode)
xcode-select --install
# Or via Homebrew:
brew install autoconf automake
# FreeBSD
pkg install autoconf automake m4
```
**Note**: If the `configure` script already exists, you don't need these tools. Only run `autoreconf` if you're building from a git repository without generated files.
**Problem**: Compilation errors about undefined functions
**Solution**: Ensure `configure` was run successfully and `config.h` exists:
```bash
./configure
make clean
make
```
**Problem**: Compilation errors about incomplete type 'WINDOW' or `curscr->_cury` / `curscr->_curx`
**Solution**: This is a known compatibility issue with modern ncurses. The codebase attempts to access internal ncurses structure members that are not available in modern ncurses libraries. See [BUILD_ISSUES.md](BUILD_ISSUES.md) for detailed information about this issue and potential fixes.
**Problem**: `make: command not found`
**Solution**: Install make:
```bash
# Debian/Ubuntu
sudo apt-get install build-essential
# RHEL/CentOS/Fedora
sudo yum groupinstall "Development Tools"
# macOS
xcode-select --install
# FreeBSD (use gmake)
pkg install gmake
# Then use: gmake instead of make
```
**Problem**: `configure: error: cannot find install-sh or install.sh`
**Solution**: The `install-sh` script should be in the repository. If missing, you may need to regenerate it:
```bash
autoreconf -fiv
```
**Problem**: Documentation build fails (missing groff/nroff/tbl)
**Solution**: Documentation tools are optional. The game will build without them, but man pages won't be generated:
```bash
# Install documentation tools (optional)
# Debian/Ubuntu
sudo apt-get install groff
# RHEL/CentOS/Fedora
sudo yum install groff
# Or skip documentation generation - the game will still build
```
### Runtime Issues
**Problem**: Screen is too small
**Solution**: Rogue requires at least 24x80 terminal. Resize your terminal or use a larger font.
**Problem**: Terminal doesn't support colors/features
**Solution**: The game will auto-detect terminal capabilities. For best experience, use a modern terminal emulator (xterm, gnome-terminal, iTerm2, etc.).
**Problem**: Save file corruption
**Solution**: Save files are encrypted. Don't edit them manually. If corrupted, delete `~/rogue.save` and start fresh.
**Problem**: Scoreboard permission errors
**Solution**: If using setgid installation:
```bash
sudo chgrp games rogue.scr
sudo chmod 664 rogue.scr
```
### Platform-Specific
**Windows (MinGW)**: Ensure PDCurses is properly linked. Check `LIBS` in Makefile.
**macOS**: If using Homebrew ncurses, you may need to specify include/library paths:
```bash
# For Intel Macs (Homebrew in /usr/local)
./configure CPPFLAGS="-I/usr/local/include" LDFLAGS="-L/usr/local/lib"
# For Apple Silicon Macs (Homebrew in /opt/homebrew)
./configure CPPFLAGS="-I/opt/homebrew/include" LDFLAGS="-L/opt/homebrew/lib"
# Or let pkg-config find it (if available)
./configure PKG_CONFIG_PATH="/opt/homebrew/lib/pkgconfig"
```
**Alternative**: If ncurses is installed but not found, you can also try:
```bash
# Check where ncurses is installed
brew --prefix ncurses
# Use that path in configure
./configure CPPFLAGS="-I$(brew --prefix ncurses)/include" LDFLAGS="-L$(brew --prefix ncurses)/lib"
```
**Cygwin**: Ensure you're using the Cygwin version of ncurses, not a Windows port.
---
## Resources
### Documentation
- **In-game help**: Press `?` during gameplay
- **Man page**: `man rogue` (after installation)
- **Game guide**: See `rogue.doc` (generated from `rogue.me.in`)
### External Resources
- **Original Rogue**: The game that started it all
- **Roguelike genre**: This game inspired NetHack, Angband, and many others
- **Roguelike development**: Great learning resource for game programming
### Related Projects
- **NetHack**: Spiritual successor with more features
- **Brogue**: Modern roguelike with beautiful ASCII graphics
- **DCSS**: Dungeon Crawl Stone Soup, another modern roguelike
---
The engine package is fully headless-testable: `make test` runs scripted command
sequences, dungeon-generation golden checks, and an RNG compatibility test
against the original C generator.
For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt +
prettier), `make lint` (`script/lint`, which runs golangci-lint inside the
pinned container built from `Dockerfile.lint` — it is never installed on the
host, so docker is required), `make test` (the suite, under the race detector
with coverage and a timeout), `make check` (all three), `make build` (the
executable), and `make cover` / `make cover-html` (per-function coverage, and
the same profile as annotated source at `build/coverage.html`). Everything they
generate lands in the git-ignored `build/`. Use the targets rather than the
toolchain directly — they carry the flags the project relies on.
## License
This project is licensed under a BSD-style license. See [LICENSE.TXT](LICENSE.TXT) for details.
**Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman**
Portions based on work by:
- Nicholas J. Kisseberth (state.c, mdport.c)
- David Burren (xcrypt.c)
---
## Acknowledgments
This is the classic Rogue game that defined the roguelike genre. Thanks to the original authors for creating this timeless game, and to all contributors who have helped maintain and improve it over the years.
**Happy dungeon crawling!**
---
*For questions, issues, or contributions, please refer to the project's issue tracker or contact the maintainers.*
BSD-style; see [LICENSE.TXT](LICENSE.TXT).
Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman.
All rights reserved.

918
TODO.md Normal file
View File

@@ -0,0 +1,918 @@
# Workflow
- branch (from `main`)
- do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
- merge to `main` if the branch is not protected, otherwise open a PR
- push
# Status
pre-1.0
The port on main is complete and faithful (function-by-function from Rogue 5.4.4
C; reference sources on c-master/modern-rogue). Current phase: refactor from a
transliterated port into idiomatic Go — one feature branch per step below,
descriptive naming, real types, house style per
~/dev/prompts/prompts/CODE_STYLEGUIDE_GO.md.
Refactor ground rules:
- Behavior must not change unless a step says so. The full test suite (scripted
sessions, generation invariants, C-compatible RNG goldens) gates every step;
80x24 seed-compatible gameplay stays intact.
- Renames keep the C lineage greppable: doc comments retain their "(file.c
func_name)" breadcrumbs, and the docs refresh step adds a C-name → Go-name
table to ARCHITECTURE.md.
# Next Step
Tag a release once a full game (Amulet retrieval and score entry) completes
without defects. Promoted from Future Steps now that the coverage step above it
is finished.
# Completed Steps
- 2026-08-10 `make cover` added (https://git.eeqj.de/sneak/rgoue/issues/17).
`make cover` writes `build/coverage.out` and prints the per-function report;
`make cover-html` renders the same profile to `build/coverage.html`. The
per-package percentage `make test` prints cannot say _which_ function is
untested, which is how the coverage gaps closed so far had to be found — by
grepping test files for identifiers.
Neither target is in `check`, and neither may be added to it: both write
files, and `make check` must not modify the working tree.
- 2026-08-10 `make build` added (https://git.eeqj.de/sneak/rgoue/issues/19). The
executable is built to `build/rogue`; `README.md` no longer contains a raw
`go` invocation anywhere. `build` is in neither `check` nor `test`
`make check` stays `fmt-check lint test` and still writes nothing into the
working tree.
Generated artifacts now all live under `build/`, which `.gitignore` covers
as a whole. Anything written outside it is committable, so a target that
puts its output elsewhere reintroduces the stray-artifact problem.
- 2026-08-10 Linting moved into a container
(https://git.eeqj.de/sneak/rgoue/issues/41). `golangci-lint` is no longer
invoked on the host anywhere in the repo: `Dockerfile.lint` pins
`golangci/golangci-lint:v2.12.2` by digest and runs the linter as a build
step, so a successful build is a clean lint, and `make lint` is now a shim
over `script/lint`. This is what killed the false green seen earlier, where a
branch that was genuinely red with a `goconst` finding reported `0 issues` off
the shared host cache; a container per run has its own cache and lock.
`script/lint` builds with `--target "$stage"`, `--no-cache-filter="$stage"`
and `--output=type=cacheonly`. The durable property to check when touching
any of this: the lint stage executes on every run and is never served from
cache. Three things no tooling checks, left to whoever edits the gate —
`$stage` must match the stage name in `Dockerfile.lint`; that stage must
stay the one running `golangci-lint`, since `--target` halts the build
there; and `.dockerignore` governs what reaches the container, so excluding
a self-contained Go source drops it from the lint silently.
Verified rather than assumed, since a green docker build is the classic
false green: two consecutive runs on an unchanged tree each showed the
`golangci-lint run` layer executing and reporting `0 issues.` while the
`deps` layers reported `CACHED`; the same build with `--no-cache-filter`
removed reported that layer `CACHED`, so the re-execution is attributable to
the flag rather than to a changed context; deliberate violations failed the
build naming the specific finding and reverted clean; a stage-name typo
failed loudly at exit 1; and a Go file excluded via `.dockerignore` reported
`0 issues.` at exit 0 with the violation still in the tree. Wall-clock
durations vary per host and per run, so they are not recorded here.
- 2026-08-09 `TestAutoSaveOnSignalRacesTurnLoop` de-flaked at the cause
(`fix/autosave-turn-budget-36`, closes #36). The failure text was captured
before anything was changed and it is **not** a data race: the assertion was
`driveUntilDone`'s
`t.Fatal("the turn loop ran out of turns before the saves were taken")`, with
no `WARNING: DATA RACE` anywhere in the log. The handoff fixed in #24 was
working; the test's own drive loop was running out of its fixed 1000-turn
budget first.
Confirmed rather than taken on trust. Instrumenting the loop to report the
turns it actually used showed the count tracking scheduling pressure and
nothing else: about 60-120 turns at host load ~57 with the whole machine to
spread over, 418 at `GOMAXPROCS=4`, 539 and 655 at 2 and 1, and past 1000 —
the recorded failure — under the doubled load of the verbose rerun that the
test target performs after a failure. The turns between one save being
answered and the next request arriving are not work; they are the saving
goroutine's wake-up latency, so a fixed turn count is a wall-clock
assumption in disguise, which is why raising it would have hidden the flake
rather than fixed it.
So the budget is gone rather than larger. `driveUntilDone` now drives until
the saving goroutine finishes and nothing else. Termination is not lost, it
just belongs to the code under test instead of to the test: every
`AutoSaveOnSignal` returns within the timeout it is handed, so the saving
goroutine always finishes. A handoff that has stopped answering costs one
`autoSaveWait` in total — `g.sigSave` is one deep, so an unserviced request
stays in the channel and every later call finds it full and fails at once —
and the failure is then the real assertion (`saves taken = 0, want 25`)
instead of "out of turns". The worst case is not that one: a handoff that
drains each request but slower than `autoSaveWait` costs one timeout per
save, `wantSaves × autoSaveWait` = 250s, which would run past the 30s
package timeout instead of reaching the assertion. It takes ~10s of
scheduler starvation per save against a measured 0.12s per 1000 turns, so it
is remote, and the turn cap did not bound it either. The comment in the test
states that bound rather than the optimistic one.
Removing the cap exposed a second assumption underneath it, which is the
reason this is not a one-line diff. `testTerm` answers space and newline for
ever once its script is exhausted, and neither key takes a turn, so
`command()` — which loops until the player consumes one — never returns; the
old cap was silently sized to the script (4000 characters, two per turn,
against 1000 turns). An uncapped drive wedged inside a single `command()`
call. The two drive tests therefore use a new `driveTerm`, a headless
terminal whose script repeats. Repeating is necessary but not sufficient,
and the test says so: `' '` clears `After` outright and all eight movement
keys clear it on a refused step, so a script of only those keys wedges just
as `testTerm`'s tail did. What makes the wedge impossible is that the cycle
always holds an _unconditional_ turn-taker, and these scripts hold two —
`'.'` (empty handler) and `'s'` (`search`, which writes `After` on no path),
neither refusable by blocked-in-all-directions, `Held`, a bear trap, or
`NoCommand > 0`. Removing both would bring the wedge back.
Both halves of the definition of done were demonstrated by mutation, with
the deliberately-broken tree reverted afterwards and `.golangci.yml` left
byte-identical (sha256 `021cc83f...46bcb`). Reverting #24
`AutoSaveOnSignal` replaced by a direct `g.autoSave()`, encoding on the
calling goroutine — still fails the test with 139 `WARNING: DATA RACE`
reports naming `snapshotHeader` reading what `executeCommand` writes, so the
guard is undiminished. Removing the `serviceAutoSaveRequest` call from
`command()` still fails it too, now in 10s with `saves taken = 0, want 25`
rather than by hanging.
Under load, an A/B at `GOMAXPROCS=2` on a 48-core host at load ~150, with an
unrelated deliberate failure in the tree so that every run took the verbose
rerun: the old code failed 8 of 8 runs with "ran out of turns"; the new code
failed 0 of 8, the only failure being the planted one. Also green across 24
concurrent unconstrained runs at load ~120, 10 runs alongside a spinner
load, and 5 runs each at `GOMAXPROCS` 1, 2 and 4. `make check` green, lint 0
issues.
- 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):
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
— and now has `game/sticks_test.go` (the zap handlers, `drain`, `fix_stick`,
`charge_str`) and `game/bolt_test.go` (the `fire_bolt` geometry). Every
expectation was read out of `sticks.c` rather than off the Go code; **no
divergence from C was found**, and three things worth knowing came out of the
reading. (1) **The bolt trail is the test instrument.** `fire_bolt` paints
each square with `dirch` and then paints `chat()` back over every square it
recorded, so on a screen nothing else has drawn on, the non-blank cells
afterwards are exactly the squares the bolt occupied — and the walls it
bounced off are absent, because C undoes the record with `c1--` and `break`s
before the `mvaddch`. That gives an exact assertion of the path and the
resting place without touching game code, and it is why the tests fire from a
square that is not the hero's (which is what `chase.c` does for dragon
breath): with the hero off the ray the run produces one message and the screen
stays readable. (2) **A bounce reverses both components of the direction, not
one.** A bolt entering a wall at 45 degrees goes back the way it came instead
of reflecting off the surface, so the diagonal-into-a-vertical-wall case is
the one that separates C's rule from the plausible wrong one, and it is
tested. (3) **The `ch != 'M'` guard on the miss message is a tautology.** `ch`
comes from `winat`, and `winat` _is_ `t_disguise` when a monster stands there
(`rogue.h` 57), so `ch == 'M'` implies `t_disguise == 'M'` and the arm can
never go quiet; it is vestigial from when 'M' was the mimic, and the test pins
the port to speaking, so nobody "tidies" it into a real silence. The
door-under-hero exception has no assertion of its own because it cannot have
one: without it the bolt bounces on the hero's own square forever, recording
nothing, and `fire_bolt` never returns — the test for it hangs rather than
fails, which the comment on it says. Determinism comes from a `pinRng` helper
that searches for a seed whose next draw is the wanted value (running the real
`Rng`, never predicting it) and from a level the tests carve themselves
through `drawRoom`, since bounce geometry and `drain`'s room/passage/door
reach only mean something against known walls and a known passage number. All
27 mutations tried against the new tests were caught.
- 2026-08-09 Trap unit-test coverage (`test/traps-coverage`, closes #14):
`trapHandlers` had eight entries and **zero** direct tests, on the one
subsystem besides combat that can kill the hero outright. New
`game/traps_test.go` (19 tests, 15 subtests) covers all eight arms of
`move.c be_trapped`, the prologue every trap runs through, and the
`rust_armor` tail `T_RUST` calls. Package coverage 56.2% -> 57.9% measured on
`main` at `bf820e3`, the branch point, before the sticks tests landed. Every
expected value is transcribed from `origin/c-master` and quoted in the file.
**No divergence from C was found.**
The issue body's trap list was wrong and the correction is the first thing
worth recording: there is no separate "poison dart" trap — `T_DART` **is**
the poisoned dart, its death message being "a poisoned dart killed you" —
and the list omitted `T_MYST`, the mystery trap, whose arm is an eleven-way
`rnd(11)` message switch. `rogue.h` 192-200 is the authority
(`T_DOOR`/`T_ARROW`/`T_SLEEP`/`T_BEAR`/`T_TELEP`/`T_DART`/`T_RUST`/`T_MYST`,
`NTRAPS` 8) and the Go `TrapKind` iota matches it index-for-index.
Three C details the tests are built around. (1) `BEARTIME` and `SLEEPTIME`
are `spread(3)` and `spread(5)` (`rogue.h` 108-109), and `spread` is
`nm - nm/20 + rnd(nm/10)`; for both, `nm/10` is 0 and C's `rnd` short
circuits a zero range without touching the generator, so each is an exact
constant that costs **no** random number — and the tests assert the no-draw
half as well as the value, because a stray draw desynchronises the
seed-compatible stream. (2) `T_ARROW` swings at `s_lvl - 1` and `T_DART` at
`s_lvl + 1`: opposite signs, which is exactly the kind of detail a
transliterating port drops. (3) The strength loss is gated on
`!ISWEARING(R_SUSTSTR) && !save(VS_POISON)`, and the `&&` is load-bearing —
with the ring on, C never rolls the save, so the arm must spend two random
numbers and not three.
Two shapes worth keeping, both forced by mutation results rather than
foresight. Damage dice are checked by a **sweep**, not one shot: `rnd(n)` is
"raw value % n", so a single draw agrees between a d6 and a d5 five times in
six and leaves the generator identical either way — the first draft's
single-trial arrow test passed with `roll(1,6)` mutated to `roll(1,5)`.
Likewise the swing arguments are pinned by a 200-trial boundary sweep at a
mid-range to-hit target: a forced hit and a forced miss cannot see a wrong
`at_lvl` or a dropped `op_arm`, because both arms are reachable at any level
and swing spends one `rnd(20)` regardless.
Mutation-proved, 33 mutations, each reverted, and every one of them is now
caught. Three were **not** caught on the first pass and the tests were
strengthened until they were, which is the useful part of the record. (a)
Deleting `new_level()` from `T_DOOR` left the suite green: `be_trapped`'s
own prologue stamps the trap glyph into the cell the hero fell through, so
"the map changed" is true even with no new level dug. The test now counts
differing cells — exactly one can change that way — and also requires the
staircase to move and the hero to be re-placed. (b) The `roll(1,6)` case
above.
(c) **`be_trapped` takes a coordinate, and which coordinate decides whether
`T_TELEP`'s `mvaddch(tc, TRAP)` does anything.** Deleting that line first
left the suite green, and the first draft wrote that off as an unavoidable
redundancy — wrongly, because the test only exercised one of the two call
sites. `move.go` 105-108 (`case Floor`) springs a trap under the hero and
passes `p.Pos`; there `tc` **is** the hero's square, the prologue has
already set its `p_ch` to `TRAP`, and `teleport()` opens by drawing
`floor_at()` — which returns `chat(hero)` — over it, so the glyph is on
screen before the line runs. But `move.go` 94-98 (`case Trap`), the ordinary
walk onto a hidden trap, passes `nh`, the square being stepped **onto**,
with the hero still on the previous square: `teleport()`'s opening `mvaddch`
paints the old square, `leave_room` writes blanks and never `TRAP`, and
nothing calls `look()` afterwards because the `case Trap` arm returns before
`finishMove` for a teleporter. There `mvaddch(tc, TRAP)` is the only writer,
exactly as C's comment says.
`TestTrapTeleportDrawsTheTrapOnTheSquareSteppedOnto` springs the trap at a
floor square next to the hero and pins it: unmutated the screen at `tc`
reads `^`, with the line deleted it reads `.`.
The other 30 each failed their own test and only their own; two also moved
`TestAutoSaveOnSignalRacesTurnLoop`, which drives real turns and is
legitimately sensitive to `BEARTIME` and to armor rusting.
Deliberately uncovered: the two death messages, "an arrow killed you" and "a
poisoned dart killed you". Each is printed immediately before `death()`,
which reaches `myExit` and `os.Exit`, so provoking either would take the
test binary with it; the hero is pinned with `fortify()` and the damage
rolls are checked by replaying C's arithmetic instead of by letting HP reach
zero. They are the only two: `rust_armor`'s `|| ISWEARING(R_SUSTARM)`
operand and its `if (!to_death)` suppression of the rust-vanishes message,
the last predicates that had no assertion, are pinned by
`TestTrapRustHonoursTheRingAndTheToDeathFlag`. This entry does **not**
rotate `Next Step`: #14 was an out-of-band gap found while surveying, not
part of the rings/sticks/wizard step.
- 2026-08-09 Ring unit-test coverage (`test/rings-coverage`, closes #5): the
first third of the standing coverage step. `game/rings.go` had **zero** tests
— not one of the 32 in the suite touched wear, removal, hand choice, or the
ring contribution to the hunger clock. New `game/rings_test.go` (17 tests, 44
subtests) covers `ringOn`, `pickRingHand`, `ringOff`, `gethand`, `ringEat` and
`ringNum`, plus the ring arm of `things.c dropcheck` (`dropRing`), which is
what actually takes a ring off. Package coverage 53.7% -> 56.2%. `Next Step`
narrowed rather than rotated: #6 and #7 are the other two thirds.
Every expected value is transcribed from `origin/c-master` (`rings.c`,
`rogue.h`, `things.c`), never from what the port returns, and the C is
quoted in the file. **No divergence from C was found**, which is the result
and is worth recording as a negative: `ringEat` is the one function here
whose being wrong would be invisible — it feeds `daemons.c`'s hunger clock,
so a bad entry is a slow drift in when the hero starves rather than anything
a playtest would notice — and it now has all fourteen ring kinds pinned to
C's table.
Three C details the tests were written around. (1) `ring_eat`'s `uses[]`
holds negatives, and a negative is **not** a cost: C computes
`eat = (rnd(-eat) == 0)`, a one-in-n chance of a single unit. (2) `R_DIGEST`
then flips the sign, so slow digestion returns 0 or **-1** and is the only
ring that gives food back. (3) `ring_num`'s switch closes with the
`otherwise` macro, which `rogue.h` 53 defines as `break;default` — so its
four labels fall through to one `sprintf` and every other kind returns `""`
from a default arm, not by falling off the end. The `RingKind` iota matches
C's `R_` numbering index-for-index, so a `uses[]` index and a `RingKind` are
the same number; `R_ADDHIT` is `RingDexterity` and `R_ADDDAM` is
`RingIncreaseDamage`.
The chance rings are checked two ways at once. Each call snapshots the
generator, runs `ringEat`, and replays C's own expression from the identical
state — which pins the one-in-n denominator, the sign flip, and the fact
that exactly one `rnd` call is spent — and a frequency check over 4000
trials backs it with a number a human can read. The non-negative entries
assert the reverse: the generator must be **untouched**, because C never
reaches `rnd` on that path and a stray call there would desynchronise the
whole game's RNG stream from C's and cost seed compatibility. That assertion
is what caught the one real bug in this work, which was in the test and not
the game: `g.Rng` is a pointer, so the first draft's snapshots aliased
instead of copying.
Two shapes worth keeping. Scripted hand answers carry an abort tail (a space
for the reprompt's `--More--`, then ESCAPE): without it a port that stopped
accepting a key would loop forever on the headless terminal's filler input
and the test would die of the 30s timeout instead of failing on its
assertion — which is exactly what the first draft did, and it was only
visible because the mutation run was inspected rather than trusted. And the
"only one hand free" case scripts the _wrong_ hand key deliberately: a port
that asked anyway consumes it and lands the ring on the wrong side, so the
test fails on a hand rather than on a hang.
Mutation-proved, 23 mutations, each reverted: breaking `pickRingHand`'s
ask/auto/reject arms, `ring_on`'s type guard, `is_current` guard and all
three effect arms, `ring_off`'s no-rings message, hand selection and ESCAPE
abort, `gethand`'s uppercase keys, ESCAPE and reprompt, `dropRing`'s hand
clearing and both effect arms, `dropcheck`'s cursed gate, three `ringUses`
entries, the `R_DIGEST` sign flip, the one-in-n roll, the empty-hand zero,
and `ring_num`'s `ISKNOW` guard, label set and `RING`-vs-`WEAPON`
formatting. Each failed its own test and only its own; stripping all three
`ring_on` effect arms failed 3 of 3. All fourteen ring kinds are exercised;
the eleven with no wear-time effect in C are documented at the foot of the
file as deliberately not given a wear/remove test, with the files their
powers actually live in, and `ring_off`'s unreachable "not wearing such a
ring" arm is documented as unreachable rather than left looking untested.
- 2026-08-09 Command dispatch audit (`audit/command-switch-coverage`, closes
#31): checked every case label in C's `command.c` against this port's
dispatch, and left the audit behind as a standing test
(`game/dispatch_test.go`) so the two cannot silently drift again. **No further
missing keys were found** — `'+'` (#11) was the only one. That is the result,
and it is worth recording as a negative: the class of bug exists, it has now
been searched for exhaustively rather than stumbled upon, and the search came
back empty. Three tables transcribe C's labels with their line numbers:
main-switch keys answered from `commandHandlers`, main-switch keys whose arms
need `dispatchKey`'s own switch (the `goto over` re-dispatches, `F`-to-`f`,
`a`, `m`), and the `if (wizard)` sub-switch. `commandHandlers` is pinned by
set equality in **both** directions: a missing key is the `'+'` bug, and an
extra key is the same bug mirrored — a MASTER debug command leaking into
ordinary play. Two traps make this audit harder than it sounds and are
documented in the file: `rogue.h` 52-53 defines `when` as `break;case`, so a
grep for `case ` finds ten of the eighty labels; and the main/wizard split is
load-bearing, since `'+'` was a divergence in ordinary play precisely because
it is a main-switch key. Confirms the port targets the MASTER build — all four
`#ifdef MASTER` sites in `command.c` are ported unconditionally, as is
`sticks.c` 237.
- 2026-08-09 Three small lost C behaviors (`fix/lost-c-behaviors`, closes #13):
grouped because each is a few lines and all are "restore something the port
dropped silently". (1) **"what a bizarre schtick!"**, `sticks.c` 237 — the
`otherwise` arm that closes `do_zap`'s switch, which `doZap` had turned into
doing nothing at all. Two things about it are easy to get wrong and are why
the fix is not one line. It is under `#ifdef MASTER`, **not** under a `wizard`
test, so in the MASTER build this port is it printed for every player — gating
it on `g.Wizard` would be issue #11's trap in reverse. And `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_, and only a
kind C had no case for is bizarre. Since C's switch covers all 14 `WS_`
values, its `otherwise` is reachable only for an `o_which` outside the table,
which is exactly what `Object.hasValidWhich` already screens for — so the
split needed no new state, just a three-way switch on handler / valid-Which /
neither. All three arms fall through to `obj.Charges--`, as C's do: even the
bizarre schtick costs a charge. Replaces the deferral comment PR #20 left
there. (2) **`CTRL('R')` now actually redraws.** C is
`after = FALSE; clearok(curscr, TRUE); wrefresh(curscr);` (`command.c`
288-291); the port called `g.refresh()`, the ordinary diffing blit, **which
cannot fix the only situation the command exists for** — a screen corrupted by
something else's output leaves the game's record of it still correct, so the
diff sends nothing and the corruption stays. New `Terminal.Repaint` (tcell
`Screen.Sync`, which discards tcell's record of the terminal instead of
diffing against it), `Screen.Repaint`, `g.repaint()`; three implementations to
update, the same shape as PR #26's `ReadChar` change, so no split was needed.
Named for the curses operation, not for tcell: the interface is the game's
abstraction. It repaints what was last rendered — C repainted `curscr`, not
`stdscr` — so it takes no window, and the arm drops the `refresh()` C never
had there (`command` refreshes before the next key read anyway). (3) **The
startup greeting**, `main.c` 107-113, which existed nowhere in the tree. New
`game.Greeting`, printed 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`. Two placement details the issue did not mention and
the tests now pin: the printf sits **after** `parse_opts`, so a ROGUEOPTS
`name=` is what the player is greeted by and the account name is only the
fallback (`Greeting` re-runs `ParseOpts`, which does nothing but assign into
fields — no RNG, no screen); 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`). The game `Greeting` parses
into is a throwaway but is built the way `New` builds the real one, tables and
home directory included, because `ParseOpts` handles every option and not just
the one the greeting reads: `inven=` is matched against `inv_t_name[]`, which
lives on the game, so a bare `&RogueGame{}` turned a legal `ROGUEOPTS` into a
nil dereference before the player saw a character. No RNG call is added on any
path and nothing under `game/testdata/` moved; `TestSeedCompatItemTables` is
green against the untouched golden. Mutation-proved, each new behaviour
deleted in turn and only its own test failing: dropping the message arm fails
`TestZapUnhandledWandSaysBizarreSchtick`; extending it to `WandNothing` fails
`TestZapWandOfNothingIsSilent`; putting `g.refresh()` back fails
`TestRedrawCommandForcesFullRepaint`; swapping the two wordings, or the
ROGUEOPTS name for the account name, fails `TestGreeting`; greeting on the
restore path fails `TestDigsNewDungeon`. ARCHITECTURE.md §5.3 gains `Repaint`
and the paragraph on why a blit cannot substitute for it. `Next Step`
deliberately not rotated: out-of-band issue work.
- 2026-08-09 The `'+'` wizard-mode toggle (`fix/wizard-toggle-off`, closes #11):
C's `command.c` 317-338 has a `when '+'` arm that leaves wizard mode —
`wizard = FALSE`, `turn_see(TRUE)`, `msg("not wizard any more")` — and the
port had no `'+'` anywhere, so the key fell through `dispatchKey`'s default to
`illcom` and answered "illegal command '+'". The password half of that arm was
dropped on purpose (wizard mode is `ROGUE_WIZARD` configuration) and is in
ARCHITECTURE.md §9; the leave half was lost silently and is not the same
decision — it does not touch the password machinery at all. **The substantive
part is `turn_see(TRUE)`**, not the flag: wizard sight draws every monster the
hero cannot see, so without the re-hide there is no way back to normal
visibility once wizard mode is on, and clearing the flag alone would have left
the screen lying. New `wizardToggleCommand` in `game/command.go`, registered
in `commandHandlers` between `'^'` and `Escape` — C's own switch order, and
note that C's arm sits in the **main** command switch under `#ifdef MASTER`,
not in the `if (wizard) switch (ch)` sub-switch that `wizardCommand` ports, so
it is reachable whether or not `wizard` is set. That makes the non-wizard case
a divergence too, and it resolves the way the dropped `passwd()` forces: a
password check that no longer exists can never succeed, so the else arm is
what C did on a wrong answer, the message "sorry" — no prompt, since nothing
typed into one could change the outcome, and none of the
`noscore`/`turn_see(FALSE)` bookkeeping of C's unreachable success branch. The
choice is stated in the function's doc comment and in §9, whose password row
now names the `'+'` enter arm and whose new paragraph records that the leave
arm is ported in full. Two tests in `game/wizard_test.go` drive `'+'` through
`g.dispatch`: the wizard one spawns a phantom (`ISINVIS` straight from the
monster table, so `seeMonst` is false and it is on screen only because wizard
sight put it there), asserts the precondition — monster glyph drawn in
standout at its cell, `SenseMonsters` set — and then asserts the flag cleared,
`SenseMonsters` cleared, the cell back to the map char under the monster with
standout off, the exact message, and `After` false; the non-wizard one pins
"sorry" and that `'+'` is no longer an illegal command. Mutation-proved:
deleting the `turnSee(true)` call fails the test on all three visibility
assertions, which is the half a flag-only test would have missed. No RNG call
is added — the `turn_off` arm of `turn_see` never reaches `rnd`, only the
turn-on arm does — and `TestSeedCompatItemTables` stays green against the
untouched golden. `Next Step` deliberately not rotated: out-of-band issue
work.
- 2026-08-09 Cleanups deferred from the PR #26 review (`cleanup/pr26-followups`,
closes #27): four items, no behaviour change. (1) The `sig-leave` entry below
still argued, in the present tense, that declining to save on SIGINT/SIGQUIT
was the safe choice because `AutoSave` encodes live state after removing the
file — both halves untrue since #24, and the entry read as a claim about how
the code works now rather than a record of what was weighed then. It is in the
past tense and marked superseded, pointing at the `fix/autosave-race` entry.
Nothing else in the file was touched — in particular the `err113` linter name
in the 2026-07-06 entry, which a `grep` for `113` still matches, and the "over
a hundred reports" wording the #26 rework had already corrected. Note for
anyone chasing this class of bug: the false claim was in the #12 entry, not
the #24 one, whose account of the old remove-then-write is correctly past
tense — find these by content, since `make fmt` reflows the file and cited
line numbers rot. (2) `encodeSnapshot` is `writeSnapshotFile`: it encodes,
fsyncs, chmods 0400 and closes, and the old name claimed only the first of
those. One call site (`saveFile`), and the doc comment now lists what it does
and why the fsync is there. (3) `TestAutoSaveOnSignalWhileInShellEscape` used
`t.Error` for its precondition, so a save that was never taken fell through
into `assertRestorable`, which can then only report a second, derived failure;
it is `t.Fatal`, matching the identical assertion in the blocked-on-input
test. (4) `serviceAutoSaveRequest`'s doc comment had a 24-column stub line
("The result is still a") left by an earlier edit — `gofmt` does not rewrap
comments, so `fmt-check` was legitimately green and nothing would ever have
caught it. Rewrapped to the block's width. `Next Step` deliberately not
rotated: out-of-band issue work.
- 2026-08-09 Signal-time autosave moved onto the game goroutine
(`fix/autosave-race`, closes #24): the SIGHUP/SIGTERM handler gob-encoded the
live game tree from the signal goroutine while the game goroutine was mid-turn
mutating it, and `AutoSave` **removed** the save file before encoding — so the
failure mode was not a stale save but a deleted one followed by a possibly
torn replacement, with a window in which the player had neither. `make test`
has run with `-race` since 2026-08-09 and was green, because no test had ever
driven the turn loop concurrently with a signal: evidence of untested, not of
safe. The handler now writes nothing itself. `AutoSaveOnSignal` posts a
request on a one-deep channel, wakes the input read, and waits up to
`signalSaveTimeout` (3s) for the game goroutine to take it; the encode happens
on the goroutine that owns the state. **The blocked-on-input case is the whole
point** — a dropped connection lands while the player is thinking, so a flag
checked only between turns would never be looked at — and it is handled by
making the read interruptible: `Terminal.ReadChar` returns `(byte, bool)` with
`ok == false` meaning "woken by `Interrupt`, no key", `term.Tcell.Interrupt`
posts a `tcell.EventInterrupt` onto tcell's own event queue to unpark
`PollEvent`, and `readchar` services the request and reads again, so no caller
sees the wake-up. The other unbounded park is the `!` shell escape, where a
hangup used to save and would otherwise have regressed to not saving: the
shell now runs on a helper goroutine and `runShellEscape` selects on {shell
finished, save request}, keeping the encode on the game goroutine while it
draws nothing. Between turns (`command`) covers a game that is busy rather
than parked. The wait is bounded so that a game goroutine wedged with no
service point can never stop a signal from getting the process out; giving up
costs nothing now that `saveFile` writes a temporary file in the save's own
directory, fsyncs it, and renames it over the target instead of truncating in
place — a failed or skipped save leaves the previous save whole. New
`game/autosave_test.go` drives the real turn loop while a second goroutine
asks for 25 saves (the interleaving that never existed before), plus the
parked-on-input case with a terminal fake that genuinely blocks, the shell
case, the deadline case (previous save byte-for-byte intact), the no-file-name
case, and the rename discipline — the last pinned by a handle opened before
the save, which still reads the old file whole after it. Each was
mutation-proved: reverting `AutoSaveOnSignal` to encode on the calling
goroutine (the pre-fix behavior) makes the turn-loop test fail under `-race`
with over a hundred reports, and removing each of the three service points
fails exactly the test for that park with its own message. `pendingSaver` now
reads the game out from under its mutex instead of delegating with it held,
because the delegated call blocks until the save is taken — the PR #23
review's N3 note, load-bearing rather than hypothetical, and pinned by a test.
The SIGINT/SIGQUIT no-save decision and the single-signal-read ordering
guarantee are untouched; `savesOnSignal`'s third ground ("safety") is
rewritten, since the corruption window it weighed no longer exists.
`MEMORY.md` stops listing signal-time autosave among the deliberate `_ =`
discards and states the new discipline; `ARCHITECTURE.md` §5.3, the `Terminal`
sketch, the C-to-Go mapping row and §9's SIGTSTP paragraph are corrected to
match. Two things review caught and this entry records so they are not undone:
moving the shell onto a helper goroutine also moved `term.Tcell.ShellEscape`'s
`panic` on a failed `Screen.Resume` there, and a panic at the top of any
goroutine kills the process without running the deferred calls of the others —
including `cmd/rogue/main.go`'s `defer t.Fini()`, so the tty would have been
left raw on exactly the path where the terminal is already broken (issue #12's
failure, reintroduced on a new path). `runShellEscape` recovers the helper's
panic and re-raises it on the game goroutine, pinned by
`TestShellEscapePanicUnwindsTheGameGoroutine`. And the doc comment took two
rounds to get right: the first version claimed in four places that nothing is
half-mutated at the `readchar` service point, and the revision that fixed that
claimed two of the three service points were between-commands. Both are false.
Only the check at the top of `command` is between commands — `readchar` is
reached from mid-command prompts, and `runShellEscape` is reached from
`shell`, an ordinary `'!'` command handler dispatched inside `command`, with
that turn's `DoDaemons(Before)`/`DoFuses(Before)` already fired and its AFTER
pass and ring effects not yet. What is actually guaranteed is that the encode
runs on the state-owning goroutine, so the snapshot is internally consistent
and restorable, though it may freeze a command half applied. `Next Step`
deliberately not rotated: out-of-band issue work.
- 2026-08-09 Signal-time terminal restore (`sig-leave`, closes #12): the port
handled only SIGHUP and SIGTERM, so SIGINT and SIGQUIT killed the process with
tcell still holding the tty, leaving the user at a shell with no echo. All
four signals now go to one `os/signal` channel read by one goroutine in
`cmd/rogue/main.go`, and every path calls `Terminal.Fini` before `os.Exit(0)`
— C's `leave()`, "leave quickly but curteously". **The decision** (written
into the `savesOnSignal` comment): SIGHUP/SIGTERM keep autosaving,
SIGINT/SIGQUIT restore and exit **without** saving. C never saves on INT or
QUIT anywhere — `leave()` is endwin-and-exit, `quit()` confirms/scores/exits,
`endit()` goes through `fatal()`, and `save.c auto_save` is reserved for
HUP/TERM — and the semantics agree: HUP/TERM are involuntary teardown worth
rescuing a game from, while INT/QUIT are a deliberate "stop now" that must not
become a one-keystroke checkpoint against a save discipline built to be
anti-save-scum. A third ground was weighed at the time and has since been
superseded: back then `AutoSave` gob-encoded live state that the main
goroutine was still mutating, after removing the old file, so declining to
save on the signals with nothing to rescue was also the option with no
corruption window. That window is gone as of the `fix/autosave-race` entry
above (#24) — the encode now runs on the game goroutine and `saveFile` renames
a temporary file into place — so nothing here should be read as a statement
about how saving works now; the split stands on C and on semantics alone, as
the current `savesOnSignal` comment says. The single-reader design closes the
window the issue warned about: a second signal arriving mid-save stays unread
in the buffer instead of exiting out from under the writer
(`TestLeaveOnSignalIgnoresLaterSignals` reproduces exactly that interleaving).
New `cmd/rogue/main_test.go` pins the membership of `handledSignals()` itself
(`TestHandledSignalsSet` — without it the rest of the file, which iterates
that set, would pass against a set that had silently lost SIGINT and SIGQUIT
again), and covers the ordering for each signal, the save/no-save split
against `savesOnSignal`, the mid-save-second-signal case, the pre-game
`pendingSaver` window, and real SIGINT/SIGQUIT/SIGHUP/SIGTERM delivered to the
test process through the same `notifySignals` wiring the game uses; the tty
leaving raw mode is the one step not checkable headlessly (it needs a
controlling terminal), and `term.Tcell.Fini` is a direct pass-through to
tcell's `Screen.Fini` that `myExit` already depends on. Two premises in the
issue turned out to be wrong and are recorded in ARCHITECTURE.md: `leave()` is
not installed on SIGINT/SIGQUIT during play (the wiring is in `mdport.c`, the
shipped build calls `md_onsignal_default()` and installs nothing, and
`leave()` appears only in the endgame paths of `rip.c`/`main.c`), and Ctrl-C
never generated SIGINT here anyway, since tcell's raw mode clears `ISIG` and
the key arrives as byte `0x03` — as it did in C, whose `setup()` calls curses
`raw()`. The real exposure is `kill -INT`/`kill -QUIT`, a SIGINT to the
process group while the `!` shell escape has the screen suspended, and the
window **after** `term.New()`: nothing is raw before it, and the handlers used
to be installed only once the game existed, leaving the restore path and
`-d`'s `DeathDemo()` — which never returns, blocking in `waitFor` inside
`death()` — running raw with no handler at all. The handlers are therefore
installed immediately after `term.New()`, with the game handed to them
afterwards via `pendingSaver`; a signal before the game exists restores the
terminal and exits with nothing to save, and the SIGHUP/SIGTERM autosave
behavior on the play path is unchanged. ARCHITECTURE.md §9 gained rows for
SIGTSTP/`tstp()` (dropped: raw mode means Ctrl-Z cannot reach us, a suspend
from the signal goroutine would race the drawing goroutine, and C armed `tstp`
only after a `restore()`; the `!` shell escape covers the need), for SIGINT
not routing to the interactive `quit()` prompt, and for `auto_save` on the
fault signals; §5.3's claim that tcell handles SIGTSTP was false — tcell
registers only SIGWINCH — and is corrected. `Next Step` deliberately not
rotated: out-of-band issue work.
- 2026-08-09 Wizard-create bounds fix (`fix/wizard-which-bounds`, closes #10):
`createObj` stored the raw `0-f` nibble as `Object.Which` with no bounds
check, so wizard mode -> `C` -> `/` -> `f` produced 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 — `'A'` gives 234 and
`'!'` gives 202 — and panicked the same way. C's `create_obj()` was equally
unchecked, but every C consumer was either a `switch` (defined for any value)
or a static-array read past the end (undefined, and survivable in practice),
whereas since refactor step 8 one game is one process, so the Go panic kills
the game with the terminal still in raw mode. Fixed at the two boundaries a
bad `Which` can enter through: `createObj` now rejects an out-of-range choice
with a message built from C's own `type_name()` vocabulary and adds nothing to
the pack (a deliberate, commented divergence, since C had no defined behavior
here to be faithful to), and `Restore` refuses a snapshot describing such an
object (`ErrSaveCorrupt`) instead of loading a game that would explode later.
Behind those, `whichLimit`/`hasValidWhich` back defensive guards at every
dispatch named in the issue: the three effect tables (the new `quaffHandler`,
`readHandler`, and `zapHandler` accessors return no handler rather than
indexing — for wands that is exactly non-`MASTER` C, which matched no case and
still ran `o_charges--`), the `callIt` lore lookups, `identifyType` (whose
table is shorter than the scroll table keying it, though no scroll that can
reach `readIdentify` overshoots it, so that one is defensive rather than a
live bound), `armorClass` for the four `a_class[]` reads, `initWeapon` against
the missing `init_dam[]` row for `WeaponFlame`, `fixStick`'s `ws_type[]` read,
and `inventoryName`, hoisted so one check covers the scroll-title read the
issue listed plus its potion-color, ring-stone, wand-material, weapon and
armor siblings. `objectWorth` got the same hoisted guard, since the
death-screen appraisal reads the identical per-kind tables. No in-range input
changes behavior and no guard consumes a random number — the rejection
precedes every `rnd()` call, verified both by an explicit seed-unchanged test
and by `TestSeedCompatItemTables` staying green untouched. New
`game/wizard_test.go`: the exact reproducer, a rejection sweep over every
indexed kind including both wrapping-input forms, an acceptance sweep proving
valid choices still build the right item, one no-panic test per guarded family
(wand/potion/scroll/armor/weapon), the `fixStick` crash site, the corrupt-save
rejection over the wrapped values and a negative `Which` (a decoded snapshot
is the only source of one, so it is what exercises the `Which >= 0` arm of
`hasValidWhich`), and a check that `whichLimit` still agrees with the table
sizes. Each guard was confirmed load-bearing by reverting it and watching the
test panic. `Next Step` deliberately not rotated: this was out-of-band issue
work.
- 2026-08-09 Stale-docs correction (`docs-staleness`, closes #3): four claims in
`MEMORY.md`/`TODO.md`/`README.md` had gone false and were misdirecting agents
— the reviewer on PR #9 repeated one of them verbatim. Each was re-verified
against the tree before rewriting. (1) `MEMORY.md` described C's `exit()`
being unwound by a `gameEnd` panic recovered in `Run`; refactor step 8 deleted
that, `gameEnd` appears nowhere in the sources, and `myExit` (`game/rip.go`)
now calls `Terminal.Fini` then `os.Exit(0)` while `Run()` never returns — so
the section states the exit model and its testing consequence (a death exits
the test binary; hence `fortify()` in `game/run_test.go`). (2) `MEMORY.md`
said approved lint exceptions live in a "Repo-specific exceptions" block in
`.golangci.yml`; no such block exists and the config is byte-identical to
canonical (sha256 `021cc83f…46bcb`), the approvals having moved to in-code
`//nolint` directives carrying their dates — and `paralleltest` was listed as
an approved disable when it was in fact fixed (no `paralleltest` token in the
tree; 32 `t.Parallel()` calls against 32 tests). (3) `MEMORY.md` "Debugging"
and (4) `README.md` both told the reader to run `go test` directly, which
since PR #9 silently drops `-timeout 30s -race -cover`; both now point at
`make test`/`make check`. Also dropped the false "currently v2.12.2" host
linter claim from the 2026-08-07 entry (the host is v2.10.1 and nothing is
pinned; the pin question is tracked separately). Documentation only — no code,
`Makefile`, or config change; `Next Step` deliberately not rotated, since this
was out-of-band issue work.
- 2026-08-09 Policy-shaped `make test` (`make-test-policy-pattern`): the `test:`
target was a bare `go test $(GO_PKGS)` and now runs
`-timeout 30s -race -cover` with the mandated conditional verbose rerun (on
failure it reruns with `-v` and then `exit 1`, so a flaky pass on the second
attempt cannot rescue the build). `$(GO_PKGS)` is kept rather than hardcoding
`./...`. The substance was `-race`, not the Makefile edit: this is the first
time the suite has run under the race detector, and it is clean — no data
races across five consecutive uncached runs, including the tcell terminal
layer and the `os.Exit`-path playthrough tests. Wall clock 5.1s cold
(including the race build) and ~2.3s warm, against the 20s policy budget. The
failure path was exercised with a throwaway failing test to confirm the rerun
fires and `make` exits non-zero. Build tooling only; no game behavior change.
- 2026-08-07 Canonical linter config (`golangci-v2.12.2`): replaced
`.golangci.yml` with the shared canonical config (v2 schema; settings now live
under `linters.settings`, so the `lll`/`funlen`/`cyclop`/`dupl` thresholds
actually apply — the old top-level `linters-settings` block was silently
ignored). The four repo-specific disables (`mnd`, `exhaustive`,
`paralleltest`, `testpackage`) moved out of the config into targeted in-code
`//nolint` directives carrying the original approval dates, so the config
stays byte-identical to canonical. Real fixes: `t.Parallel()` in all 32 tests,
24 long lines wrapped or their comments tightened, control bytes in
`term/tcell.go` as character literals, and two `wsl_v5` defer cuddles. The
repo has no golangci-lint version pin to bump (no Dockerfile or CI;
`make lint` runs whatever `golangci-lint` is on the host). Superseded
2026-08-10: there is a pin now, and no host lint path — `Dockerfile.lint` pins
the linter image by digest and `script/lint` runs it in a container. See the
2026-08-10 entry at the top of this section
(https://git.eeqj.de/sneak/rgoue/issues/41).
- 2026-07-24 Seed compatibility — item tables (seed-compat): instrumented the C
reference on modern-rogue with a DUMP mode (testdata/c_seedcompat.patch) that
forces the RNG seed and prints the per-seed item appearance tables (potion
colors, scroll names, ring stones, wand/staff materials) before initscr, and
captured its output for four seeds as testdata/item_tables.golden.
TestSeedCompatItemTables regenerates the same tables from the Go port and they
match byte for byte — proving the LCG and its consumption order through the
whole init sequence agree with C. The remaining "same dungeon (map)" half
would need the harder headless-curses C dump (new_level draws to curses);
deferred — the item-table match already validates RNG-order faithfulness
through init, and the Go generation goldens guard determinism thereafter.
- 2026-07-23 Playtest hardening (playtest-hardening): added two death-safe
crash-sweep drives through the real turn loop, within the step-8 os.Exit
constraint (a fortify() helper pins HP/food/exp and clears the freeze/stuck
counters each turn so no death exits the test binary; fixed seeds keep them
deterministic). TestDeepPlaythrough uses quaff/read/zap through command
dispatch, then descends to depth 8 with a save/restore at depth 4;
TestTurnLoopCrashSweep mashes movement/search/rest for 200 turns on four
seeds. Neither surfaced a panic. The interactive "play several games at a real
tcell terminal" portion needs a human at an 80x24 terminal and is left to the
maintainer; the binary's non-interactive paths (`-s` scores) were
smoke-tested.
- 2026-07-23 Docs refresh (docs-refresh): rewrote ARCHITECTURE.md Part 2 (the
pre-implementation design sketch) to match the final code — current type/field
names (ObjectKind, DiceSpec, split o_arm, step-1 flag names, TrapCount, Level
list methods), the static tables now on the per-game gameData struct, the
daemon/effect handler tables, the MessageLine extraction, the Terminal
interface, the flat gob SaveState, and the New(Params) + os.Exit design. Added
§7.1, a C-name → Go-name rename table, and a README note on the make targets.
- 2026-07-23 Refactor step 8 (refactor/constructor-style): constructor and exit
pass. NewGame(Config) → New(Params) and Restore takes Params, so the package's
primary type gets the canonical New() constructor with a named-field Params
struct (styleguide 139/159). The gameEnd panic unwind is gone: one game run is
one process, so myExit restores the terminal (new Terminal.Fini) and calls
os.Exit(0), and Run() no longer returns; the four Run()-to-completion tests
were reworked/dropped since death (combat or starvation) now exits the process
(TestScoreRendersList and TestRunDownStairs preserve what is still drivable;
save/restore stays covered by TestSaveRestoreRoundTrip). The 77-column wrap
sweep was dropped per sneak (2026-07-23): line lengths left as-is (lll caps at
88 and passes).
- 2026-07-07 Refactor step 7 (refactor/effects-dispatch): effects dispatch
tables plus a full decomposition sweep — the quaff / readScroll / doZap
switches, the attack monster-power switch, the be_trapped switch, the daemon
d_func switch, and the command-key switch all became handler tables on
gameData (quaffHandlers, readHandlers, zapHandlers, hitHandlers, trapHandlers,
daemonHandlers, commandHandlers), one small named method per case. Every
remaining cyclop/gocognit/nestif hot spot was split into named helpers across
fight, misc (look), command, chase, move, passages, options, pack, things,
save, daemons, rooms, score, monsters, rings, rip, io, object, weapons,
wizard, and term/tcell, plus three test functions. Effect order and RNG call
sequence preserved throughout; the whole golangci-lint run is now 0 issues.
- 2026-07-07 Refactor step 6 (refactor/god-object-extraction): MessageLine (was
MsgLine) owns the msg/addmsg/endmsg machinery, wired to its screen/look/input
needs via attach(); RogueGame keeps one-line msg/addmsgf/endmsg shorthands so
call sites are unchanged. Player owns pack bookkeeping (nextPackChar,
removeFromPack — the state half of leave_pack; leavePack keeps only LastPick
tracking). Level owns object/monster list management and lookup (ObjectAt
replaces findObj; AddObject/RemoveObject/AddMonster/RemoveMonster replace
direct attachObj/detachObj/attachMon/detachMon on level lists).
Inventory/pickup UI flows stay on RogueGame deliberately: they are display and
turn orchestration, not state surgery.
- 2026-07-07 Refactor step 5 (refactor/item-combat-ui-renames, three commits,
one subsystem each): items — getItem→promptPackItem now returning (obj, ok),
invName→inventoryName, doPot→applyPotionFuse; combat — rollEm→rollAttacks,
attack/moveMonster/chaseStep return (removed bool) instead of C -1/0 int
codes; UI — getDir→promptDirection. C breadcrumbs kept; suite green.
- 2026-07-07 Refactor step 4 (refactor/movement-renames): movement/world renames
(doMove→moveHero, beTrapped→springTrap, rndmove→randomStep,
doRooms/doPassages/doMaze→digRooms/digPassages/digMaze, chgStr→changeStrength,
doRun→startRun, moveStuff→finishMove, turnref→turnRefresh,
moveMonst→moveMonster, doChase→chaseStep, setOldch→setOldChar, cansee→canSee,
roomin→roomIn, runto→runTo, conn→connectRooms, putpass→putPassage,
passnum→numberPassages, numpass→numberPassage, rndPos→randomPos,
rndRoom→randomRoom, treasRoom→treasureRoom, accntMaze→accountMaze); all
goto/label flows replaced with loops (moveHero retry loop + extracted
passageTurn, dispatch re-dispatch loop, chaseStep passage loop, saveGame
labeled prompt loop); C breadcrumbs kept in doc comments.
- 2026-07-07 Lint adoption finished (refactor/no-package-globals): all 37
package-level vars moved into `gameData` (built by `newGameData`, hung on
RogueGame as `g.data`, set in NewGame and Restore); ObjectKind
Glyph()/objectKindForGlyph became switches; the table-reading subtype
Stringers were removed; isMagic became a RogueGame method; goconst fixed with
named word constants (potionName, goldName, staffName, ripWall, ...);
testpackage and exhaustive disabled in .golangci.yml with sneak's approval
(2026-07-07); misspell's corruption of the "ther" scroll syllable reverted.
mnd disabled with sneak's approval (2026-07-07, follow-up commit). Remaining
red: cyclop (36), nestif (30), gocognit (23) stay until step 7 fixes them per
sneak's ruling.
- 2026-07-06 Lint adoption bulk (refactor/lint-adoption, 5ba9fe8): .golangci.yml
copied verbatim from the prompts repo (plus the sneak-approved paralleltest
exception, 2026-07-06); ~1,500 findings fixed (autofix formatting sweep,
errcheck/err113/noinlineerr error handling, forbidigo, funcorder, recvcheck
pointer receivers, goprintffuncname renames msg helpers to *f, revive doc
comments, gocritic switch rewrites, gosec real fixes plus justified nolints,
unparam signature tightening, C-faithful "missle" spellings restored after
misspell autofix changed game text).
- 2026-07-06 Module base path updated to git.eeqj.de/sneak/rgoue (go.mod, term/
and cmd/ imports, ARCHITECTURE.md, version string).
- 2026-07-06 Refactor step 3 (refactor/object-fields): Object.Arm split into
ArmorClass/Charges/GoldValue/Bonus (rings); Stats.Arm → ArmorClass; damage
strings parsed once into DiceSpec at table definition (ParseDice keeps C
roll_em parse semantics, incl. "%%%x0" and "000x0" edge cases,
regression-tested); save format 5.4.4-go3.
- 2026-07-06 Refactor step 2 (refactor/typed-kinds, b940cfc): ObjectKind
separates item category from map glyph (Object.Type byte → Kind ObjectKind
with Glyph()); PotionKind/ScrollKind/RingKind/
WandKind/WeaponKind/ArmorKind/TrapKind typed iota enums with Stringer; typed
accessors on Object; getItem/inventory/whatis filters take ObjectKind
(KindCallable/KindRingOrStick replace CALLABLE/R_OR_S); save format bumped to
5.4.4-go2. Suite green.
- 2026-07-06 Refactor step 1 (refactor/descriptive-constants): renamed all flag
bits, trap types, item subtype constants, and Max* counts to descriptive names
(IsHuh→Confused, SeeMonst→SenseMonsters, WsHasteM→WandHasteMonster,
MaxSticks→NumWandTypes, ...); Level.NTraps→TrapCount; C names kept as comment
breadcrumbs. Pure rename, suite green.
- 2026-07-06 Made the rgoue branch Go-only: removed C sources and the
autoconf/VS build system (they remain on master and modern-rogue), ported the
last wizard command (item-probability listing), rewrote README.md for the Go
port (c0b533e)
- 2026-07-06 Ported the command loop, save/restore, the tcell terminal layer,
and the playable binary at cmd/rogue (41fc104)
- 2026-07-06 Ported item effects: potions, scrolls, options, call_it (cdf9bf7)
- 2026-07-06 Ported combat, the chase driver, traps, zapping, death and scores
(3c5add8)
- 2026-07-06 Ported dungeon generation, base items, the pack, and monster
creation (a69ef7d)
- 2026-07-06 Ported the foundation: types, seed-compatible RNG, item tables,
daemon scheduler (7fa2048)
- 2026-07-06 Wrote ARCHITECTURE.md Parts 1 and 2: complete map of the C program
and the Go port design (91eeee0, 45dba95)
- Fork base: Davidslv/rogue C 5.4.4 with modernization fixes (C23 prototypes,
ncurses compat), preserved on master/modern-rogue
# Future Steps
1. Full-terminal-size support (deferred by explicit decision 2026-07-06):
per-game dungeon dimensions instead of the 80x24 constants; open design
questions are resize policy, gameplay tuning at larger sizes, and a --classic
80x24 mode.
2. Note: this repo is exempt from the standard policy scaffold, but the
exemption is narrower than it was. A minimal dev Makefile
(fmt/fmt-check/lint/test/check targets) exists per sneak's 2026-07-07
request. `Dockerfile.lint` and `script/lint` are now also permitted, and
required, along with the `.dockerignore` that scopes their build context:
sneak's 2026-08-09 ruling (https://git.eeqj.de/sneak/rgoue/issues/41) is that
every repo lints in a container invoked through `script/lint`, and being
later and explicit it overrides the 2026-07-07 exemption for those three
files only. Still do not add: CI config, `REPO_POLICIES.md`, an application
`Dockerfile`, or any other `script/` entrypoint.

89
armor.c
View File

@@ -1,89 +0,0 @@
/*
* This file contains misc functions for dealing with armor
* @(#)armor.c 4.14 (Berkeley) 02/05/99
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <curses.h>
#include "rogue.h"
/*
* wear:
* The player wants to wear something, so let him/her put it on.
*/
void
wear()
{
register THING *obj;
register char *sp;
if ((obj = get_item("wear", ARMOR)) == NULL)
return;
if (cur_armor != NULL)
{
addmsg("you are already wearing some");
if (!terse)
addmsg(". You'll have to take it off first");
endmsg();
after = FALSE;
return;
}
if (obj->o_type != ARMOR)
{
msg("you can't wear that");
return;
}
waste_time();
obj->o_flags |= ISKNOW;
sp = inv_name(obj, TRUE);
cur_armor = obj;
if (!terse)
addmsg("you are now ");
msg("wearing %s", sp);
}
/*
* take_off:
* Get the armor off of the players back
*/
void
take_off()
{
register THING *obj;
if ((obj = cur_armor) == NULL)
{
after = FALSE;
if (terse)
msg("not wearing armor");
else
msg("you aren't wearing any armor");
return;
}
if (!dropcheck(cur_armor))
return;
cur_armor = NULL;
if (terse)
addmsg("was");
else
addmsg("you used to be");
msg(" wearing %c) %s", obj->o_packch, inv_name(obj, TRUE));
}
/*
* waste_time:
* Do nothing but let other things happen
*/
void
waste_time()
{
do_daemons(BEFORE);
do_fuses(BEFORE);
do_daemons(AFTER);
do_fuses(AFTER);
}

541
chase.c
View File

@@ -1,541 +0,0 @@
/*
* Code for one creature to chase another
*
* @(#)chase.c 4.57 (Berkeley) 02/05/99
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <stdlib.h>
#include <curses.h>
#include "rogue.h"
#define DRAGONSHOT 5 /* one chance in DRAGONSHOT that a dragon will flame */
static coord ch_ret; /* Where chasing takes you */
/*
* runners:
* Make all the running monsters move.
*/
void
runners()
{
register THING *tp;
THING *next;
bool wastarget;
static coord orig_pos;
for (tp = mlist; tp != NULL; tp = next)
{
/* remember this in case the monster's "next" is changed */
next = next(tp);
if (!on(*tp, ISHELD) && on(*tp, ISRUN))
{
orig_pos = tp->t_pos;
wastarget = on(*tp, ISTARGET);
if (move_monst(tp) == -1)
continue;
if (on(*tp, ISFLY) && dist_cp(&hero, &tp->t_pos) >= 3)
move_monst(tp);
if (wastarget && !ce(orig_pos, tp->t_pos))
{
tp->t_flags &= ~ISTARGET;
to_death = FALSE;
}
}
}
if (has_hit)
{
endmsg();
has_hit = FALSE;
}
}
/*
* move_monst:
* Execute a single turn of running for a monster
*/
int
move_monst(THING *tp)
{
if (!on(*tp, ISSLOW) || tp->t_turn)
if (do_chase(tp) == -1)
return(-1);
if (on(*tp, ISHASTE))
if (do_chase(tp) == -1)
return(-1);
tp->t_turn ^= TRUE;
return(0);
}
/*
* relocate:
* Make the monster's new location be the specified one, updating
* all the relevant state.
*/
void
relocate(THING *th, coord *new_loc)
{
struct room *oroom;
if (!ce(*new_loc, th->t_pos))
{
mvaddch(th->t_pos.y, th->t_pos.x, th->t_oldch);
th->t_room = roomin(new_loc);
set_oldch(th, new_loc);
oroom = th->t_room;
moat(th->t_pos.y, th->t_pos.x) = NULL;
if (oroom != th->t_room)
th->t_dest = find_dest(th);
th->t_pos = *new_loc;
moat(new_loc->y, new_loc->x) = th;
}
move(new_loc->y, new_loc->x);
if (see_monst(th))
addch(th->t_disguise);
else if (on(player, SEEMONST))
{
standout();
addch(th->t_type);
standend();
}
}
/*
* do_chase:
* Make one thing chase another.
*/
int
do_chase(THING *th)
{
register coord *cp;
register struct room *rer, *ree; /* room of chaser, room of chasee */
register int mindist = 32767, curdist;
register bool stoprun = FALSE; /* TRUE means we are there */
register bool door;
register THING *obj;
static coord this; /* Temporary destination for chaser */
rer = th->t_room; /* Find room of chaser */
if (on(*th, ISGREED) && rer->r_goldval == 0)
th->t_dest = &hero; /* If gold has been taken, run after hero */
if (th->t_dest == &hero) /* Find room of chasee */
ree = proom;
else
ree = roomin(th->t_dest);
/*
* We don't count doors as inside rooms for this routine
*/
door = (chat(th->t_pos.y, th->t_pos.x) == DOOR);
/*
* If the object of our desire is in a different room,
* and we are not in a corridor, run to the door nearest to
* our goal.
*/
over:
if (rer != ree)
{
for (cp = rer->r_exit; cp < &rer->r_exit[rer->r_nexits]; cp++)
{
curdist = dist_cp(th->t_dest, cp);
if (curdist < mindist)
{
this = *cp;
mindist = curdist;
}
}
if (door)
{
rer = &passages[flat(th->t_pos.y, th->t_pos.x) & F_PNUM];
door = FALSE;
goto over;
}
}
else
{
this = *th->t_dest;
/*
* For dragons check and see if (a) the hero is on a straight
* line from it, and (b) that it is within shooting distance,
* but outside of striking range.
*/
if (th->t_type == 'D' && (th->t_pos.y == hero.y || th->t_pos.x == hero.x
|| abs(th->t_pos.y - hero.y) == abs(th->t_pos.x - hero.x))
&& dist_cp(&th->t_pos, &hero) <= BOLT_LENGTH * BOLT_LENGTH
&& !on(*th, ISCANC) && rnd(DRAGONSHOT) == 0)
{
delta.y = sign(hero.y - th->t_pos.y);
delta.x = sign(hero.x - th->t_pos.x);
if (has_hit)
endmsg();
fire_bolt(&th->t_pos, &delta, "flame");
running = FALSE;
count = 0;
quiet = 0;
if (to_death && !on(*th, ISTARGET))
{
to_death = FALSE;
kamikaze = FALSE;
}
return(0);
}
}
/*
* This now contains what we want to run to this time
* so we run to it. If we hit it we either want to fight it
* or stop running
*/
if (!chase(th, &this))
{
if (ce(this, hero))
{
return( attack(th) );
}
else if (ce(this, *th->t_dest))
{
for (obj = lvl_obj; obj != NULL; obj = next(obj))
if (th->t_dest == &obj->o_pos)
{
detach(lvl_obj, obj);
attach(th->t_pack, obj);
chat(obj->o_pos.y, obj->o_pos.x) =
(th->t_room->r_flags & ISGONE) ? PASSAGE : FLOOR;
th->t_dest = find_dest(th);
break;
}
if (th->t_type != 'F')
stoprun = TRUE;
}
}
else
{
if (th->t_type == 'F')
return(0);
}
relocate(th, &ch_ret);
/*
* And stop running if need be
*/
if (stoprun && ce(th->t_pos, *(th->t_dest)))
th->t_flags &= ~ISRUN;
return(0);
}
/*
* set_oldch:
* Set the oldch character for the monster
*/
void
set_oldch(THING *tp, coord *cp)
{
char sch;
if (ce(tp->t_pos, *cp))
return;
sch = tp->t_oldch;
tp->t_oldch = CCHAR( mvinch(cp->y,cp->x) );
if (!on(player, ISBLIND))
{
if ((sch == FLOOR || tp->t_oldch == FLOOR) &&
(tp->t_room->r_flags & ISDARK))
tp->t_oldch = ' ';
else if (dist_cp(cp, &hero) <= LAMPDIST && see_floor)
tp->t_oldch = chat(cp->y, cp->x);
}
}
/*
* see_monst:
* Return TRUE if the hero can see the monster
*/
bool
see_monst(THING *mp)
{
int y, x;
if (on(player, ISBLIND))
return FALSE;
if (on(*mp, ISINVIS) && !on(player, CANSEE))
return FALSE;
y = mp->t_pos.y;
x = mp->t_pos.x;
if (dist(y, x, hero.y, hero.x) < LAMPDIST)
{
if (y != hero.y && x != hero.x &&
!step_ok(chat(y, hero.x)) && !step_ok(chat(hero.y, x)))
return FALSE;
return TRUE;
}
if (mp->t_room != proom)
return FALSE;
return ((bool)!(mp->t_room->r_flags & ISDARK));
}
/*
* runto:
* Set a monster running after the hero.
*/
void
runto(coord *runner)
{
register THING *tp;
/*
* If we couldn't find him, something is funny
*/
#ifdef MASTER
if ((tp = moat(runner->y, runner->x)) == NULL)
msg("couldn't find monster in runto at (%d,%d)", runner->y, runner->x);
#else
tp = moat(runner->y, runner->x);
#endif
/*
* Start the beastie running
*/
tp->t_flags |= ISRUN;
tp->t_flags &= ~ISHELD;
tp->t_dest = find_dest(tp);
}
/*
* chase:
* Find the spot for the chaser(er) to move closer to the
* chasee(ee). Returns TRUE if we want to keep on chasing later
* FALSE if we reach the goal.
*/
bool
chase(THING *tp, coord *ee)
{
register THING *obj;
register int x, y;
register int curdist, thisdist;
register coord *er = &tp->t_pos;
register char ch;
register int plcnt = 1;
static coord tryp;
/*
* If the thing is confused, let it move randomly. Invisible
* Stalkers are slightly confused all of the time, and bats are
* quite confused all the time
*/
if ((on(*tp, ISHUH) && rnd(5) != 0) || (tp->t_type == 'P' && rnd(5) == 0)
|| (tp->t_type == 'B' && rnd(2) == 0))
{
/*
* get a valid random move
*/
ch_ret = *rndmove(tp);
curdist = dist_cp(&ch_ret, ee);
/*
* Small chance that it will become un-confused
*/
if (rnd(20) == 0)
tp->t_flags &= ~ISHUH;
}
/*
* Otherwise, find the empty spot next to the chaser that is
* closest to the chasee.
*/
else
{
register int ey, ex;
/*
* This will eventually hold where we move to get closer
* If we can't find an empty spot, we stay where we are.
*/
curdist = dist_cp(er, ee);
ch_ret = *er;
ey = er->y + 1;
if (ey >= NUMLINES - 1)
ey = NUMLINES - 2;
ex = er->x + 1;
if (ex >= NUMCOLS)
ex = NUMCOLS - 1;
for (x = er->x - 1; x <= ex; x++)
{
if (x < 0)
continue;
tryp.x = x;
for (y = er->y - 1; y <= ey; y++)
{
tryp.y = y;
if (!diag_ok(er, &tryp))
continue;
ch = winat(y, x);
if (step_ok(ch))
{
/*
* If it is a scroll, it might be a scare monster scroll
* so we need to look it up to see what type it is.
*/
if (ch == SCROLL)
{
for (obj = lvl_obj; obj != NULL; obj = next(obj))
{
if (y == obj->o_pos.y && x == obj->o_pos.x)
break;
}
if (obj != NULL && obj->o_which == S_SCARE)
continue;
}
/*
* It can also be a Xeroc, which we shouldn't step on
*/
if ((obj = moat(y, x)) != NULL && obj->t_type == 'X')
continue;
/*
* If we didn't find any scrolls at this place or it
* wasn't a scare scroll, then this place counts
*/
thisdist = dist(y, x, ee->y, ee->x);
if (thisdist < curdist)
{
plcnt = 1;
ch_ret = tryp;
curdist = thisdist;
}
else if (thisdist == curdist && rnd(++plcnt) == 0)
{
ch_ret = tryp;
curdist = thisdist;
}
}
}
}
}
return (bool)(curdist != 0 && !ce(ch_ret, hero));
}
/*
* roomin:
* Find what room some coordinates are in. NULL means they aren't
* in any room.
*/
struct room *
roomin(coord *cp)
{
register struct room *rp;
register char *fp;
fp = &flat(cp->y, cp->x);
if (*fp & F_PASS)
return &passages[*fp & F_PNUM];
for (rp = rooms; rp < &rooms[MAXROOMS]; rp++)
if (cp->x <= rp->r_pos.x + rp->r_max.x && rp->r_pos.x <= cp->x
&& cp->y <= rp->r_pos.y + rp->r_max.y && rp->r_pos.y <= cp->y)
return rp;
msg("in some bizarre place (%d, %d)", unc(*cp));
#ifdef MASTER
abort();
return NULL;
#else
return NULL;
#endif
}
/*
* diag_ok:
* Check to see if the move is legal if it is diagonal
*/
bool
diag_ok(coord *sp, coord *ep)
{
if (ep->x < 0 || ep->x >= NUMCOLS || ep->y <= 0 || ep->y >= NUMLINES - 1)
return FALSE;
if (ep->x == sp->x || ep->y == sp->y)
return TRUE;
return (bool)(step_ok(chat(ep->y, sp->x)) && step_ok(chat(sp->y, ep->x)));
}
/*
* cansee:
* Returns true if the hero can see a certain coordinate.
*/
bool
cansee(int y, int x)
{
register struct room *rer;
static coord tp;
if (on(player, ISBLIND))
return FALSE;
if (dist(y, x, hero.y, hero.x) < LAMPDIST)
{
if (flat(y, x) & F_PASS)
if (y != hero.y && x != hero.x &&
!step_ok(chat(y, hero.x)) && !step_ok(chat(hero.y, x)))
return FALSE;
return TRUE;
}
/*
* We can only see if the hero in the same room as
* the coordinate and the room is lit or if it is close.
*/
tp.y = y;
tp.x = x;
return (bool)((rer = roomin(&tp)) == proom && !(rer->r_flags & ISDARK));
}
/*
* find_dest:
* find the proper destination for the monster
*/
coord *
find_dest(THING *tp)
{
register THING *obj;
register int prob;
if ((prob = monsters[tp->t_type - 'A'].m_carry) <= 0 || tp->t_room == proom
|| see_monst(tp))
return &hero;
for (obj = lvl_obj; obj != NULL; obj = next(obj))
{
if (obj->o_type == SCROLL && obj->o_which == S_SCARE)
continue;
if (roomin(&obj->o_pos) == tp->t_room && rnd(100) < prob)
{
for (tp = mlist; tp != NULL; tp = next(tp))
if (tp->t_dest == &obj->o_pos)
break;
if (tp == NULL)
return &obj->o_pos;
}
}
return &hero;
}
/*
* dist:
* Calculate the "distance" between to points. Actually,
* this calculates d^2, not d, but that's good enough for
* our purposes, since it's only used comparitively.
*/
int
dist(int y1, int x1, int y2, int x2)
{
return ((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1));
}
/*
* dist_cp:
* Call dist() with appropriate arguments for coord pointers
*/
int
dist_cp(coord *c1, coord *c2)
{
return dist(c1->y, c1->x, c2->y, c2->x);
}

336
cmd/rogue/main.go Normal file
View File

@@ -0,0 +1,336 @@
// Command rogue is the Go port of Rogue 5.4.4: Exploring the Dungeons of
// Doom. It is a faithful function-by-function port of the classic C game;
// see ARCHITECTURE.md at the repository root.
package main
import (
"flag"
"fmt"
"os"
"os/signal"
"os/user"
"strconv"
"sync"
"syscall"
"time"
"git.eeqj.de/sneak/rgoue/game"
"git.eeqj.de/sneak/rgoue/term"
)
func main() {
os.Exit(run())
}
// run does the real work and returns an exit code. It only returns on a
// startup error; once the game starts, it ends by exiting the process
// from within (game.myExit restores the terminal first). The deferred
// Fini covers the early-return paths.
func run() int {
scores := flag.Bool("s", false, "print the scoreboard and exit")
deathDemo := flag.Bool("d", false, "die a random death (demo)")
flag.Parse()
params := loadParams()
if *scores {
game.New(params).ShowScores()
return 0
}
// C printed its greeting just before initscr(); here that means just
// before the tcell screen takes the terminal, and on stdout, exactly
// as C did (main.c main). C followed the printf with fflush because
// its stdout was buffered; os.Stdout is not, so the write is the
// flush.
if digsNewDungeon(*deathDemo, flag.Args()) {
_, _ = fmt.Fprint(os.Stdout, game.Greeting(params)) // CLI output
}
t, err := term.New()
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
defer t.Fini()
// Armed here, the instant the tty goes raw, not after the game is
// built: everything below this line — the restore, the death demo
// (which never returns), the game itself — would otherwise run raw
// with no handler installed. There is no game to save yet, so the
// saver is filled in below once there is one.
pending := installSignalHandlers(t)
params.Term = t
var g *game.RogueGame
if args := flag.Args(); len(args) == 1 && !*deathDemo {
// restore a saved game
g, err = game.Restore(args[0], params)
if err != nil {
fmt.Fprintln(os.Stderr, err) // deferred Fini restores the terminal
return 1
}
} else {
g = game.New(params)
}
if *deathDemo {
// The demo is left without a saver on purpose: a signal still
// restores the terminal, but a throwaway demo game is not worth
// writing over the player's save file.
g.DeathDemo() // does not return: death exits the process
return 0
}
pending.set(g)
g.Run() // does not return: the game ends by exiting the process
return 0
}
// digsNewDungeon reports whether this invocation is the one that digs a
// fresh dungeon, and so the only one that greets.
//
// C's printf is the last statement before initscr(), and everything that
// does something else has already left by then: -s scores and exits, -d
// runs the death demo and exits, and restore() — the argc == 2 case that
// is neither — never returns. So a saved game resumes without a greeting,
// which is right: nothing is being dug.
//
// The restore test is duplicated from run's own, deliberately. Keeping
// them as one predicate would mean deciding the startup path before the
// terminal exists and carrying it past the error returns, which is more
// rearrangement of run than a greeting is worth.
func digsNewDungeon(deathDemo bool, args []string) bool {
return !deathDemo && len(args) != 1
}
// loadParams gathers the game parameters from the environment: home
// directory, ROGUEOPTS, user name, wizard mode, and the dungeon seed
// (main.c's startup).
func loadParams() game.Params {
home, _ := os.UserHomeDir()
name := ""
u, userErr := user.Current()
if userErr == nil {
name = u.Username
}
wizard := os.Getenv("ROGUE_WIZARD") != ""
return game.Params{
Seed: chooseSeed(wizard),
Name: name,
RogueOpts: os.Getenv("ROGUEOPTS"),
Home: home,
ScorePath: home + "/.rogue.scores",
Wizard: wizard,
}
}
// saver is the autosave half of *game.RogueGame that the signal handler
// needs; an interface so the handler is testable headlessly.
type saver interface {
// AutoSaveOnSignal asks the game goroutine to write the save file and
// waits up to timeout for it, reporting whether the save ran (save.c
// auto_save). The handler never encodes anything itself; see
// signalSaveTimeout.
AutoSaveOnSignal(timeout time.Duration) bool
}
// signalSaveTimeout bounds how long the signal handler waits for the game
// goroutine to take its autosave.
//
// The handler cannot encode the game itself — that was issue #24's data
// race — so it has to hand the work to the goroutine that owns the state
// and wait. The game answers between turns, while parked waiting for a
// key, and while parked in the shell escape, which covers everywhere it
// can sit for any length of time; the deadline is the backstop for a game
// goroutine wedged somewhere with no service point, so that a signal can
// never fail to get the process out. It is generous next to the
// milliseconds a gob encode of one game takes, and invisible to a player
// whose connection has already dropped.
//
// Giving up costs nothing now that saveFile renames over the target
// (game/save.go): a save that does not happen leaves the previous save
// whole, where the old remove-then-encode could leave the player with
// neither.
const signalSaveTimeout = 3 * time.Second
// finisher is the terminal-restoring half of game.Terminal that the
// signal handler needs (curses endwin).
type finisher interface {
// Fini restores the terminal to its pre-game state.
Fini()
}
// pendingSaver is the saver the signal handler holds from the moment the
// terminal goes raw. The handler has to be armed before there is a game
// to save — restoring a save file and the death demo both run with the
// tty already raw — so AutoSaveOnSignal does nothing until set hands over
// the real game. The mutex is not decoration: set runs on the main
// goroutine and AutoSaveOnSignal on the signal goroutine.
type pendingSaver struct {
mu sync.Mutex
game saver
}
// AutoSaveOnSignal saves the game if there is one yet, and otherwise does
// nothing: a signal arriving before the game is built still restores the
// terminal, which is the part that matters.
//
// The lock is held only long enough to read the game, not across the
// delegated save. That changed with issue #24: the real
// AutoSaveOnSignal now blocks until the game goroutine takes the save or
// the deadline expires, and holding the mutex across a wait that long
// would stall a concurrent set — the case the PR #23 review flagged as
// safe only for as long as set is called exactly once. This shape does
// not depend on that.
func (p *pendingSaver) AutoSaveOnSignal(timeout time.Duration) bool {
p.mu.Lock()
g := p.game
p.mu.Unlock()
if g == nil {
return false
}
return g.AutoSaveOnSignal(timeout)
}
// set hands the signal handler the game to autosave, once one exists.
func (p *pendingSaver) set(g saver) {
p.mu.Lock()
defer p.mu.Unlock()
p.game = g
}
// handledSignals returns the signals the game leaves on. They split into
// two groups with deliberately different save behavior; see
// savesOnSignal.
func handledSignals() []os.Signal {
return []os.Signal{
syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT,
}
}
// savesOnSignal reports whether the game should autosave on its way out
// for this signal.
//
// THE DECISION (issue #12): SIGHUP and SIGTERM save; SIGINT and SIGQUIT
// restore the terminal and exit WITHOUT saving. This is deliberate, not
// an oversight, on three grounds.
//
// C: no path in the C game saves on INT or QUIT. The shipped build
// installs no handler at all during play (mach_dep.c setup calls
// md_onsignal_default), and the only INT handler it ever installs is
// rip.c/main.c's leave() in the endgame — endwin and exit, explicitly
// discarding pending output. The build that does wire INT during play
// (md_onsignal_autosave, mdport.c — defined unconditionally, but with
// its only call site, mach_dep.c setup, inside #ifdef DUMP) sends it to
// quit(), which confirms, scores, and exits, again without saving, and
// sends QUIT to endit() -> fatal() -> endwin + exit. save.c auto_save is
// reserved for HUP/TERM. Saving on HUP/TERM but not on INT/QUIT is
// therefore exactly C's split.
//
// Semantics: HUP and TERM mean involuntary teardown — the line dropped
// or the machine is going down — so rescuing the game is right. INT and
// QUIT are the player deliberately saying "stop now". Rogue scores a
// deliberate quit, and its save discipline is anti-save-scum by design
// (restoring consumes the file), so making Ctrl-C a free checkpoint
// would turn it into a one-keystroke undo for a bad turn: a gameplay
// change, not a robustness fix.
//
// Safety: this used to be the third ground, back when the handler
// gob-encoded live game state from its own goroutine after removing the
// save file — a data race with a window in which the player had no save
// at all, accepted on HUP/TERM because the process was dying anyway and
// avoided entirely on INT/QUIT. Issue #24 removed the window instead of
// living with it: the handler now hands the save to the game goroutine
// and waits (AutoSaveOnSignal), and the write goes to a temporary file
// renamed over the target. The split above stands on C and on semantics,
// which is where it always belonged; INT and QUIT do not save because
// the player asked to stop, not because saving is dangerous.
func savesOnSignal(sig os.Signal) bool {
return sig == syscall.SIGHUP || sig == syscall.SIGTERM
}
// installSignalHandlers arranges for the game to leave the terminal
// usable when it is signalled: C's leave(), "leave quickly but
// curteously" (main.c), extended with save.c auto_save on the two
// signals that warrant it.
//
// Call it the instant the terminal goes raw, which is earlier than the
// game exists; the returned pendingSaver takes the game once it does.
// Restoring the terminal is what has to be armed the moment the tty
// stops being usable, and it does not need a game.
func installSignalHandlers(t finisher) *pendingSaver {
pending := &pendingSaver{}
go leaveOnSignal(notifySignals(), pending, t, os.Exit)
return pending
}
// notifySignals subscribes to the handled signals and returns the
// channel they arrive on. Split out from installSignalHandlers so tests
// can drive leaveOnSignal with real signal delivery.
func notifySignals() chan os.Signal {
// Buffered so signal delivery never blocks, and deliberately never
// drained past the first signal: see leaveOnSignal.
sig := make(chan os.Signal, 1)
signal.Notify(sig, handledSignals()...)
return sig
}
// leaveOnSignal waits for one signal and takes the game out.
//
// Exactly one goroutine reads exactly one signal, which is what makes
// the exit safe: a second signal (a SIGINT landing while a SIGHUP's save
// is still being written, say) stays in the buffer unread and can never
// call exit out from under an in-flight save. The order within is the
// same one myExit uses (game/rip.go): save if this signal saves, then
// restore the terminal, then exit.
//
// AutoSaveOnSignal returns once the game goroutine has finished writing,
// or once signalSaveTimeout has run out, so the save is complete before
// the terminal is torn down and the process leaves — and the process
// leaves either way.
func leaveOnSignal(sig <-chan os.Signal, g saver, t finisher, exit func(int)) {
if savesOnSignal(<-sig) {
g.AutoSaveOnSignal(signalSaveTimeout)
}
t.Fini()
exit(0)
}
// chooseSeed picks the dungeon number: SEED for reproducible dungeons
// (wizard mode, as in the C game), else time+pid (main.c).
func chooseSeed(wizard bool) int32 {
if env := os.Getenv("SEED"); env != "" && wizard {
n, err := strconv.ParseInt(env, 10, 32)
if err == nil {
return int32(n)
}
}
// The C game computed `lowtime + getpid()` in int; the truncation to
// 32 bits is the same wraparound the C int arithmetic performed.
//nolint:mnd // C-faithful: the C int wraparound mask
return int32(time.Now().Unix()&0x7fffffff) +
int32(os.Getpid()&0x7fffffff)
}

419
cmd/rogue/main_test.go Normal file
View File

@@ -0,0 +1,419 @@
package main
// White-box tests for the signal plumbing. Unlike the game package's test
// files this one carries no //nolint:testpackage directive: testpackage
// exempts package main, so nolintlint rejects the directive as unused.
import (
"os"
"os/signal"
"slices"
"sync"
"syscall"
"testing"
"time"
)
// The steps the signal handler can take, in the order signalRecorder
// records them.
const (
stepSave = "save"
stepFini = "fini"
stepExit = "exit"
)
// wantHandledSignals is the exact set of signals the game must leave on.
// This is the subject of issue #12: SIGHUP and SIGTERM were handled and
// SIGINT and SIGQUIT were not, so the latter two killed the process with
// the tty still raw. Every other test here iterates handledSignals(), so
// without this one the whole file would pass against a set that had
// silently lost SIGINT and SIGQUIT again.
func wantHandledSignals() []os.Signal {
return []os.Signal{
syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT,
}
}
// wantSteps is the expected handler step sequence for each handled
// signal, and the single source of truth for the tests that check the
// save/no-save split.
func wantSteps() map[os.Signal][]string {
return map[os.Signal][]string{
syscall.SIGHUP: {stepSave, stepFini, stepExit},
syscall.SIGTERM: {stepSave, stepFini, stepExit},
syscall.SIGINT: {stepFini, stepExit},
syscall.SIGQUIT: {stepFini, stepExit},
}
}
// signalRecorder stands in for the game and the terminal in the signal
// handler, recording the order of the steps the handler takes. The mutex
// matters: the handler runs on its own goroutine, so an unguarded slice
// would be a data race under -race, which is exactly what these tests
// are meant to rule out.
type signalRecorder struct {
mu sync.Mutex
steps []string
code int
done chan struct{}
}
func newSignalRecorder() *signalRecorder {
return &signalRecorder{done: make(chan struct{})}
}
// AutoSaveOnSignal records a save attempt (the saver half). The real one
// hands the work to the game goroutine and waits; the recorder stands in
// for a game that takes it immediately.
func (r *signalRecorder) AutoSaveOnSignal(time.Duration) bool {
r.record(stepSave)
return true
}
// Fini records a terminal restore (the finisher half).
func (r *signalRecorder) Fini() {
r.record(stepFini)
}
// exit records the process exit that ends the handler and releases any
// waiter. It stands in for os.Exit, which cannot be called in a test.
func (r *signalRecorder) exit(code int) {
r.mu.Lock()
r.code = code
r.steps = append(r.steps, stepExit)
r.mu.Unlock()
close(r.done)
}
// record appends one step.
func (r *signalRecorder) record(step string) {
r.mu.Lock()
defer r.mu.Unlock()
r.steps = append(r.steps, step)
}
// taken returns the recorded steps and the exit code.
func (r *signalRecorder) taken() ([]string, int) {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.steps), r.code
}
// TestHandledSignalsSet pins the membership of handledSignals() itself.
// The regression issue #12 exists to prevent is a signal dropping out of
// that set — SIGINT and SIGQUIT reaching the process at SIG_DFL and
// killing it with the tty raw — and every other test in this file is
// driven by the set, so only this test can fail on it.
func TestHandledSignalsSet(t *testing.T) {
t.Parallel()
got := handledSignals()
want := wantHandledSignals()
if len(got) != len(want) {
t.Errorf("handledSignals() = %v, want exactly %v", got, want)
}
for _, sig := range want {
if !slices.Contains(got, sig) {
t.Errorf("handledSignals() = %v, missing %v", got, sig)
}
}
for _, sig := range got {
if !slices.Contains(want, sig) {
t.Errorf("handledSignals() = %v, unexpected %v", got, sig)
}
}
}
// TestLeaveOnSignalRestoresTerminalBeforeExit is the core of issue #12:
// whatever the signal, the terminal is restored before the process ends,
// so the player is never dropped into a shell with the tty still in raw
// mode.
func TestLeaveOnSignalRestoresTerminalBeforeExit(t *testing.T) {
t.Parallel()
for _, sig := range handledSignals() {
rec := newSignalRecorder()
ch := make(chan os.Signal, 1)
ch <- sig
leaveOnSignal(ch, rec, rec, rec.exit)
steps, code := rec.taken()
fini := slices.Index(steps, stepFini)
exit := slices.Index(steps, stepExit)
if fini < 0 || exit < 0 || fini > exit {
t.Errorf("%v: want the terminal restored before exit, got %v",
sig, steps)
}
if code != 0 {
t.Errorf("%v: exit code = %d, want 0", sig, code)
}
}
}
// TestLeaveOnSignalSaveSplit pins the decision recorded on savesOnSignal:
// SIGHUP/SIGTERM (involuntary teardown) save on the way out, SIGINT and
// SIGQUIT (a deliberate "stop now" from the player) do not, matching C,
// where auto_save is reserved for HUP/TERM and neither leave() nor quit()
// nor endit() writes a save file.
//
// It is driven by the expectation table rather than by
// handledSignals(), so that every entry — including the SIGINT and
// SIGQUIT ones — is actually read, and a signal dropped from the handled
// set fails here as well as in TestHandledSignalsSet.
func TestLeaveOnSignalSaveSplit(t *testing.T) {
t.Parallel()
for sig, want := range wantSteps() {
if !slices.Contains(handledSignals(), sig) {
t.Errorf("%v is not handled at all, so it cannot exit cleanly", sig)
continue
}
rec := newSignalRecorder()
ch := make(chan os.Signal, 1)
ch <- sig
leaveOnSignal(ch, rec, rec, rec.exit)
steps, _ := rec.taken()
if !slices.Equal(steps, want) {
t.Errorf("%v: steps = %v, want %v", sig, steps, want)
}
if saved := slices.Contains(steps, stepSave); saved != savesOnSignal(sig) {
t.Errorf("%v: saved = %v, savesOnSignal = %v",
sig, saved, savesOnSignal(sig))
}
}
}
// TestLeaveOnSignalIgnoresLaterSignals covers the ordering guarantee in
// leaveOnSignal's comment: only the first signal is read, so a second one
// arriving mid-save cannot exit out from under the save and truncate the
// player's file. The saver here blocks until a second signal has been
// queued, reproducing that window.
func TestLeaveOnSignalIgnoresLaterSignals(t *testing.T) {
t.Parallel()
rec := newSignalRecorder()
ch := make(chan os.Signal, 2)
ch <- syscall.SIGHUP
blocker := &blockingSaver{rec: rec, queue: ch, extra: syscall.SIGINT}
leaveOnSignal(ch, blocker, rec, rec.exit)
steps, _ := rec.taken()
if !slices.Equal(steps, []string{stepSave, stepFini, stepExit}) {
t.Errorf("steps = %v, want one save, one fini, one exit", steps)
}
if len(ch) != 1 {
t.Errorf("queued signals left unread = %d, want 1", len(ch))
}
}
// blockingSaver queues another signal while the save is in flight, the
// race window leaveOnSignal is built to close.
type blockingSaver struct {
rec *signalRecorder
queue chan os.Signal
extra os.Signal
}
// AutoSaveOnSignal delivers the extra signal mid-save, then records the
// save.
func (b *blockingSaver) AutoSaveOnSignal(timeout time.Duration) bool {
b.queue <- b.extra
return b.rec.AutoSaveOnSignal(timeout)
}
// TestLeaveOnRealSignal is the deepest headless check available: it
// delivers real SIGINT/SIGQUIT/SIGHUP/SIGTERM to this process through
// os/signal, exactly as notifySignals wires them in the game, and
// verifies each one reaches the handler and produces the full expected
// step sequence — including the save/no-save split, which this test is
// the best placed to check end to end.
//
// What cannot be checked here is the tty itself coming back out of raw
// mode: that needs a controlling terminal and a live tcell screen, which
// a headless test run does not have. This test covers everything up to
// the Terminal.Fini call; term.Tcell.Fini is a direct pass-through to
// tcell's Screen.Fini, which is the same call myExit already relies on.
func TestLeaveOnRealSignal(t *testing.T) {
t.Parallel()
ch := notifySignals()
defer signal.Stop(ch)
for _, sig := range handledSignals() {
rec := newSignalRecorder()
go leaveOnSignal(ch, rec, rec, rec.exit)
unix, ok := sig.(syscall.Signal)
if !ok {
t.Fatalf("%v is not a unix signal", sig)
}
err := syscall.Kill(os.Getpid(), unix)
if err != nil {
t.Fatalf("kill(%v): %v", sig, err)
}
<-rec.done
steps, code := rec.taken()
if want := wantSteps()[sig]; !slices.Equal(steps, want) {
t.Errorf("%v: steps = %v, want %v", sig, steps, want)
}
if code != 0 {
t.Errorf("%v: exit code = %d, want 0", sig, code)
}
}
}
// TestPendingSaverArmsBeforeTheGameExists covers what lets the handlers
// be installed the instant the terminal goes raw rather than after the
// game is built: a signal arriving before there is a game must still
// reach Fini, and must not save anything, while one arriving after the
// game is handed over saves it.
func TestPendingSaverArmsBeforeTheGameExists(t *testing.T) {
t.Parallel()
pending := &pendingSaver{}
rec := newSignalRecorder()
ch := make(chan os.Signal, 1)
ch <- syscall.SIGHUP
// No game yet: the SIGHUP still restores the terminal and exits, it
// just has nothing to write.
leaveOnSignal(ch, pending, rec, rec.exit)
steps, code := rec.taken()
if want := []string{stepFini, stepExit}; !slices.Equal(steps, want) {
t.Errorf("before the game exists: steps = %v, want %v", steps, want)
}
if code != 0 {
t.Errorf("before the game exists: exit code = %d, want 0", code)
}
// Once the game is handed over, the same saver writes it.
started := newSignalRecorder()
pending.set(started)
if !pending.AutoSaveOnSignal(signalSaveTimeout) {
t.Error("after set: the save was not reported as taken")
}
saved, _ := started.taken()
if want := []string{stepSave}; !slices.Equal(saved, want) {
t.Errorf("after set: steps = %v, want %v", saved, want)
}
}
// TestPendingSaverDoesNotHoldItsLockAcrossTheSave pins the reason
// pendingSaver reads the game out from under the mutex instead of
// delegating with it held: since issue #24 the delegated save blocks
// until the game goroutine takes it or the deadline expires, so a mutex
// held across it would stall whoever calls set. Nothing calls set twice
// today, which is why the PR #23 review recorded this as a future-proof
// note rather than a bug — this test is what stops it becoming one.
func TestPendingSaverDoesNotHoldItsLockAcrossTheSave(t *testing.T) {
t.Parallel()
pending := &pendingSaver{}
stuck := &stuckSaver{entered: make(chan struct{}), release: make(chan struct{})}
pending.set(stuck)
go pending.AutoSaveOnSignal(signalSaveTimeout)
<-stuck.entered // the delegated save is in flight
done := make(chan struct{})
go func() {
defer close(done)
pending.set(newSignalRecorder()) // must not block on the save
}()
select {
case <-done:
case <-time.After(time.Second):
t.Error("set blocked while a save was in flight: the lock is held across it")
}
close(stuck.release)
}
// stuckSaver blocks inside the delegated save until it is released,
// standing in for a game goroutine that is slow to answer.
type stuckSaver struct {
entered chan struct{}
release chan struct{}
}
// AutoSaveOnSignal blocks until the test releases it.
func (s *stuckSaver) AutoSaveOnSignal(time.Duration) bool {
close(s.entered)
<-s.release
return true
}
// TestDigsNewDungeon pins which invocations reach C's greeting. In main.c
// the printf is the last statement before initscr(), so -s and -d, which
// exit earlier, never see it, and neither does a restored game, because
// restore() does not return. The saved-game case is the one worth having
// a test for: resuming a dungeon must not announce that one is being dug.
func TestDigsNewDungeon(t *testing.T) {
t.Parallel()
cases := []struct {
name string
deathDemo bool
args []string
want bool
}{
{name: "new game", args: nil, want: true},
{name: "restore a save", args: []string{"rogue.save"}, want: false},
{name: "death demo", deathDemo: true, want: false},
{
name: "death demo wins over a save argument",
deathDemo: true,
args: []string{"rogue.save"},
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := digsNewDungeon(tc.deathDemo, tc.args); got != tc.want {
t.Errorf("digsNewDungeon(%v, %v) = %v, want %v",
tc.deathDemo, tc.args, got, tc.want)
}
})
}
}

820
command.c
View File

@@ -1,820 +0,0 @@
/*
* Read and execute the user commands
*
* @(#)command.c 4.73 (Berkeley) 08/06/83
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <stdlib.h>
#include <string.h>
#include <curses.h>
#include <ctype.h>
#include "rogue.h"
/*
* command:
* Process the user commands
*/
void
command()
{
register char ch;
register int ntimes = 1; /* Number of player moves */
char *fp;
THING *mp;
static char countch, direction, newcount = FALSE;
if (on(player, ISHASTE))
ntimes++;
/*
* Let the daemons start up
*/
do_daemons(BEFORE);
do_fuses(BEFORE);
while (ntimes--)
{
again = FALSE;
if (has_hit)
{
endmsg();
has_hit = FALSE;
}
/*
* these are illegal things for the player to be, so if any are
* set, someone's been poking in memeory
*/
if (on(player, ISSLOW|ISGREED|ISINVIS|ISREGEN|ISTARGET))
exit(1);
look(TRUE);
if (!running)
door_stop = FALSE;
status();
lastscore = purse;
move(hero.y, hero.x);
if (!((running || count) && jump))
refresh(); /* Draw screen */
take = 0;
after = TRUE;
/*
* Read command or continue run
*/
#ifdef MASTER
if (wizard)
noscore = TRUE;
#endif
if (!no_command)
{
if (running || to_death)
ch = runch;
else if (count)
ch = countch;
else
{
ch = readchar();
move_on = FALSE;
if (mpos != 0) /* Erase message if its there */
msg("");
}
}
else
ch = '.';
if (no_command)
{
if (--no_command == 0)
{
player.t_flags |= ISRUN;
msg("you can move again");
}
}
else
{
/*
* check for prefixes
*/
newcount = FALSE;
if (isdigit(ch))
{
count = 0;
newcount = TRUE;
while (isdigit(ch))
{
count = count * 10 + (ch - '0');
if (count > 255)
count = 255;
ch = readchar();
}
countch = ch;
/*
* turn off count for commands which don't make sense
* to repeat
*/
switch (ch)
{
case CTRL('B'): case CTRL('H'): case CTRL('J'):
case CTRL('K'): case CTRL('L'): case CTRL('N'):
case CTRL('U'): case CTRL('Y'):
case '.': case 'a': case 'b': case 'h': case 'j':
case 'k': case 'l': case 'm': case 'n': case 'q':
case 'r': case 's': case 't': case 'u': case 'y':
case 'z': case 'B': case 'C': case 'H': case 'I':
case 'J': case 'K': case 'L': case 'N': case 'U':
case 'Y':
#ifdef MASTER
case CTRL('D'): case CTRL('A'):
#endif
break;
default:
count = 0;
}
}
/*
* execute a command
*/
if (count && !running)
count--;
if (ch != 'a' && ch != ESCAPE && !(running || count || to_death))
{
l_last_comm = last_comm;
l_last_dir = last_dir;
l_last_pick = last_pick;
last_comm = ch;
last_dir = '\0';
last_pick = NULL;
}
over:
switch (ch)
{
case ',': {
THING *obj = NULL;
int found = 0;
for (obj = lvl_obj; obj != NULL; obj = next(obj))
{
if (obj->o_pos.y == hero.y && obj->o_pos.x == hero.x)
{
found=1;
break;
}
}
if (found) {
if (levit_check())
;
else
pick_up((char)obj->o_type);
}
else {
if (!terse)
addmsg("there is ");
addmsg("nothing here");
if (!terse)
addmsg(" to pick up");
endmsg();
}
}
when '!': shell();
when 'h': do_move(0, -1);
when 'j': do_move(1, 0);
when 'k': do_move(-1, 0);
when 'l': do_move(0, 1);
when 'y': do_move(-1, -1);
when 'u': do_move(-1, 1);
when 'b': do_move(1, -1);
when 'n': do_move(1, 1);
when 'H': do_run('h');
when 'J': do_run('j');
when 'K': do_run('k');
when 'L': do_run('l');
when 'Y': do_run('y');
when 'U': do_run('u');
when 'B': do_run('b');
when 'N': do_run('n');
when CTRL('H'): case CTRL('J'): case CTRL('K'): case CTRL('L'):
case CTRL('Y'): case CTRL('U'): case CTRL('B'): case CTRL('N'):
{
if (!on(player, ISBLIND))
{
door_stop = TRUE;
firstmove = TRUE;
}
if (count && !newcount)
ch = direction;
else
{
ch += ('A' - CTRL('A'));
direction = ch;
}
goto over;
}
when 'F':
kamikaze = TRUE;
/* FALLTHROUGH */
case 'f':
if (!get_dir())
{
after = FALSE;
break;
}
delta.y += hero.y;
delta.x += hero.x;
if ( ((mp = moat(delta.y, delta.x)) == NULL)
|| ((!see_monst(mp)) && !on(player, SEEMONST)))
{
if (!terse)
addmsg("I see ");
msg("no monster there");
after = FALSE;
}
else if (diag_ok(&hero, &delta))
{
to_death = TRUE;
max_hit = 0;
mp->t_flags |= ISTARGET;
runch = ch = dir_ch;
goto over;
}
when 't':
if (!get_dir())
after = FALSE;
else
missile(delta.y, delta.x);
when 'a':
if (last_comm == '\0')
{
msg("you haven't typed a command yet");
after = FALSE;
}
else
{
ch = last_comm;
again = TRUE;
goto over;
}
when 'q': quaff();
when 'Q':
after = FALSE;
q_comm = TRUE;
quit(0);
q_comm = FALSE;
when 'i': after = FALSE; inventory(pack, 0);
when 'I': after = FALSE; picky_inven();
when 'd': drop();
when 'r': read_scroll();
when 'e': eat();
when 'w': wield();
when 'W': wear();
when 'T': take_off();
when 'P': ring_on();
when 'R': ring_off();
when 'o': option(); after = FALSE;
when 'c': call(); after = FALSE;
when '>': after = FALSE; d_level();
when '<': after = FALSE; u_level();
when '?': after = FALSE; help();
when '/': after = FALSE; identify();
when 's': search();
when 'z':
if (get_dir())
do_zap();
else
after = FALSE;
when 'D': after = FALSE; discovered();
when CTRL('P'): after = FALSE; msg(huh);
when CTRL('R'):
after = FALSE;
clearok(curscr,TRUE);
wrefresh(curscr);
when 'v':
after = FALSE;
msg("version %s. (mctesq was here)", release);
when 'S':
after = FALSE;
save_game();
when '.': ; /* Rest command */
when ' ': after = FALSE; /* "Legal" illegal command */
when '^':
after = FALSE;
if (get_dir()) {
delta.y += hero.y;
delta.x += hero.x;
fp = &flat(delta.y, delta.x);
if (!terse)
addmsg("You have found ");
if (chat(delta.y, delta.x) != TRAP)
msg("no trap there");
else if (on(player, ISHALU))
msg(tr_name[rnd(NTRAPS)]);
else {
msg(tr_name[*fp & F_TMASK]);
*fp |= F_SEEN;
}
}
#ifdef MASTER
when '+':
after = FALSE;
if (wizard)
{
wizard = FALSE;
turn_see(TRUE);
msg("not wizard any more");
}
else
{
wizard = passwd();
if (wizard)
{
noscore = TRUE;
turn_see(FALSE);
msg("you are suddenly as smart as Ken Arnold in dungeon #%d", dnum);
}
else
msg("sorry");
}
#endif
when ESCAPE: /* Escape */
door_stop = FALSE;
count = 0;
after = FALSE;
again = FALSE;
when 'm':
move_on = TRUE;
if (!get_dir())
after = FALSE;
else
{
ch = dir_ch;
countch = dir_ch;
goto over;
}
when ')': current(cur_weapon, "wielding", NULL);
when ']': current(cur_armor, "wearing", NULL);
when '=':
current(cur_ring[LEFT], "wearing",
terse ? "(L)" : "on left hand");
current(cur_ring[RIGHT], "wearing",
terse ? "(R)" : "on right hand");
when '@':
stat_msg = TRUE;
status();
stat_msg = FALSE;
after = FALSE;
otherwise:
after = FALSE;
#ifdef MASTER
if (wizard) switch (ch)
{
case '|': msg("@ %d,%d", hero.y, hero.x);
when 'C': create_obj();
when '$': msg("inpack = %d", inpack);
when CTRL('G'): inventory(lvl_obj, 0);
when CTRL('W'): whatis(FALSE, 0);
when CTRL('D'): level++; new_level();
when CTRL('A'): level--; new_level();
when CTRL('F'): show_map();
when CTRL('T'): teleport();
when CTRL('E'): msg("food left: %d", food_left);
when CTRL('C'): add_pass();
when CTRL('X'): turn_see(on(player, SEEMONST));
when CTRL('~'):
{
THING *item;
if ((item = get_item("charge", STICK)) != NULL)
item->o_charges = 10000;
}
when CTRL('I'):
{
int i;
THING *obj;
for (i = 0; i < 9; i++)
raise_level();
/*
* Give him a sword (+1,+1)
*/
obj = new_item();
init_weapon(obj, TWOSWORD);
obj->o_hplus = 1;
obj->o_dplus = 1;
add_pack(obj, TRUE);
cur_weapon = obj;
/*
* And his suit of armor
*/
obj = new_item();
obj->o_type = ARMOR;
obj->o_which = PLATE_MAIL;
obj->o_arm = -5;
obj->o_flags |= ISKNOW;
obj->o_count = 1;
obj->o_group = 0;
cur_armor = obj;
add_pack(obj, TRUE);
}
when '*' :
pr_list();
otherwise:
illcom(ch);
}
else
#endif
illcom(ch);
}
/*
* turn off flags if no longer needed
*/
if (!running)
door_stop = FALSE;
}
/*
* If he ran into something to take, let him pick it up.
*/
if (take != 0)
pick_up(take);
if (!running)
door_stop = FALSE;
if (!after)
ntimes++;
}
do_daemons(AFTER);
do_fuses(AFTER);
if (ISRING(LEFT, R_SEARCH))
search();
else if (ISRING(LEFT, R_TELEPORT) && rnd(50) == 0)
teleport();
if (ISRING(RIGHT, R_SEARCH))
search();
else if (ISRING(RIGHT, R_TELEPORT) && rnd(50) == 0)
teleport();
}
/*
* illcom:
* What to do with an illegal command
*/
void
illcom(int ch)
{
save_msg = FALSE;
count = 0;
msg("illegal command '%s'", unctrl(ch));
save_msg = TRUE;
}
/*
* search:
* player gropes about him to find hidden things.
*/
void
search()
{
register int y, x;
register char *fp;
register int ey, ex;
int probinc;
bool found;
ey = hero.y + 1;
ex = hero.x + 1;
probinc = (on(player, ISHALU) ? 3 : 0);
probinc += (on(player, ISBLIND) ? 2 : 0);
found = FALSE;
for (y = hero.y - 1; y <= ey; y++)
for (x = hero.x - 1; x <= ex; x++)
{
if (y == hero.y && x == hero.x)
continue;
fp = &flat(y, x);
if (!(*fp & F_REAL))
switch (chat(y, x))
{
case '|':
case '-':
if (rnd(5 + probinc) != 0)
break;
chat(y, x) = DOOR;
msg("a secret door");
foundone:
found = TRUE;
*fp |= F_REAL;
count = FALSE;
running = FALSE;
break;
case FLOOR:
if (rnd(2 + probinc) != 0)
break;
chat(y, x) = TRAP;
if (!terse)
addmsg("you found ");
if (on(player, ISHALU))
msg(tr_name[rnd(NTRAPS)]);
else {
msg(tr_name[*fp & F_TMASK]);
*fp |= F_SEEN;
}
goto foundone;
break;
case ' ':
if (rnd(3 + probinc) != 0)
break;
chat(y, x) = PASSAGE;
goto foundone;
}
}
if (found)
look(FALSE);
}
/*
* help:
* Give single character help, or the whole mess if he wants it
*/
void
help()
{
register struct h_list *strp;
register char helpch;
register int numprint, cnt;
msg("character you want help for (* for all): ");
helpch = readchar();
mpos = 0;
/*
* If its not a *, print the right help string
* or an error if he typed a funny character.
*/
if (helpch != '*')
{
move(0, 0);
for (strp = helpstr; strp->h_desc != NULL; strp++)
if (strp->h_ch == helpch)
{
lower_msg = TRUE;
msg("%s%s", unctrl(strp->h_ch), strp->h_desc);
lower_msg = FALSE;
return;
}
msg("unknown character '%s'", unctrl(helpch));
return;
}
/*
* Here we print help for everything.
* Then wait before we return to command mode
*/
numprint = 0;
for (strp = helpstr; strp->h_desc != NULL; strp++)
if (strp->h_print)
numprint++;
if (numprint & 01) /* round odd numbers up */
numprint++;
numprint /= 2;
if (numprint > LINES - 1)
numprint = LINES - 1;
wclear(hw);
cnt = 0;
for (strp = helpstr; strp->h_desc != NULL; strp++)
if (strp->h_print)
{
wmove(hw, cnt % numprint, cnt >= numprint ? COLS / 2 : 0);
if (strp->h_ch)
waddstr(hw, unctrl(strp->h_ch));
waddstr(hw, strp->h_desc);
if (++cnt >= numprint * 2)
break;
}
wmove(hw, LINES - 1, 0);
waddstr(hw, "--Press space to continue--");
wrefresh(hw);
wait_for(' ');
clearok(stdscr, TRUE);
/*
refresh();
*/
msg("");
touchwin(stdscr);
wrefresh(stdscr);
}
/*
* identify:
* Tell the player what a certain thing is.
*/
void
identify()
{
register int ch;
register struct h_list *hp;
register char *str;
static struct h_list ident_list[] = {
{'|', "wall of a room", FALSE},
{'-', "wall of a room", FALSE},
{GOLD, "gold", FALSE},
{STAIRS, "a staircase", FALSE},
{DOOR, "door", FALSE},
{FLOOR, "room floor", FALSE},
{PLAYER, "you", FALSE},
{PASSAGE, "passage", FALSE},
{TRAP, "trap", FALSE},
{POTION, "potion", FALSE},
{SCROLL, "scroll", FALSE},
{FOOD, "food", FALSE},
{WEAPON, "weapon", FALSE},
{' ', "solid rock", FALSE},
{ARMOR, "armor", FALSE},
{AMULET, "the Amulet of Yendor", FALSE},
{RING, "ring", FALSE},
{STICK, "wand or staff", FALSE},
{'\0'}
};
msg("what do you want identified? ");
ch = readchar();
mpos = 0;
if (ch == ESCAPE)
{
msg("");
return;
}
if (isupper(ch))
str = monsters[ch-'A'].m_name;
else
{
str = "unknown character";
for (hp = ident_list; hp->h_ch != '\0'; hp++)
if (hp->h_ch == ch)
{
str = hp->h_desc;
break;
}
}
msg("'%s': %s", unctrl(ch), str);
}
/*
* d_level:
* He wants to go down a level
*/
void
d_level()
{
if (levit_check())
return;
if (chat(hero.y, hero.x) != STAIRS)
msg("I see no way down");
else
{
level++;
seenstairs = FALSE;
new_level();
}
}
/*
* u_level:
* He wants to go up a level
*/
void
u_level()
{
if (levit_check())
return;
if (chat(hero.y, hero.x) == STAIRS)
if (amulet)
{
level--;
if (level == 0)
total_winner();
new_level();
msg("you feel a wrenching sensation in your gut");
}
else
msg("your way is magically blocked");
else
msg("I see no way up");
}
/*
* levit_check:
* Check to see if she's levitating, and if she is, print an
* appropriate message.
*/
bool
levit_check()
{
if (!on(player, ISLEVIT))
return FALSE;
msg("You can't. You're floating off the ground!");
return TRUE;
}
/*
* call:
* Allow a user to call a potion, scroll, or ring something
*/
void
call()
{
register THING *obj;
register struct obj_info *op = NULL;
register char **guess, *elsewise = NULL;
register bool *know;
obj = get_item("call", CALLABLE);
/*
* Make certain that it is somethings that we want to wear
*/
if (obj == NULL)
return;
switch (obj->o_type)
{
case RING:
op = &ring_info[obj->o_which];
elsewise = r_stones[obj->o_which];
goto norm;
when POTION:
op = &pot_info[obj->o_which];
elsewise = p_colors[obj->o_which];
goto norm;
when SCROLL:
op = &scr_info[obj->o_which];
elsewise = s_names[obj->o_which];
goto norm;
when STICK:
op = &ws_info[obj->o_which];
elsewise = ws_made[obj->o_which];
norm:
know = &op->oi_know;
guess = &op->oi_guess;
if (*guess != NULL)
elsewise = *guess;
when FOOD:
msg("you can't call that anything");
return;
otherwise:
guess = &obj->o_label;
know = NULL;
elsewise = obj->o_label;
}
if (know != NULL && *know)
{
msg("that has already been identified");
return;
}
if (elsewise != NULL && elsewise == *guess)
{
if (!terse)
addmsg("Was ");
msg("called \"%s\"", elsewise);
}
if (terse)
msg("call it: ");
else
msg("what do you want to call it? ");
if (elsewise == NULL)
strcpy(prbuf, "");
else
strcpy(prbuf, elsewise);
if (get_str(prbuf, stdscr) == NORM)
{
if (*guess != NULL)
free(*guess);
*guess = malloc((unsigned int) strlen(prbuf) + 1);
strcpy(*guess, prbuf);
}
}
/*
* current:
* Print the current weapon/armor
*/
void
current(THING *cur, char *how, char *where)
{
after = FALSE;
if (cur != NULL)
{
if (!terse)
addmsg("you are %s (", how);
inv_describe = FALSE;
addmsg("%c) %s", cur->o_packch, inv_name(cur, TRUE));
inv_describe = TRUE;
if (where)
addmsg(" %s", where);
endmsg();
}
else
{
if (!terse)
addmsg("you are ");
addmsg("%s nothing", how);
if (where)
addmsg(" %s", where);
endmsg();
}
}

1500
config.guess vendored

File diff suppressed because it is too large Load Diff

View File

@@ -1,269 +0,0 @@
/* config.h.in. Generated from configure.ac by autoheader. */
/* Define if scorefile is top scores, not top players */
#undef ALLSCORES
/* Define if checktime feature should be enabled */
#undef CHECKTIME
/* Define to group owner of setgid executable */
#undef GROUPOWNER
/* Define to 1 if you have the `alarm' function. */
#undef HAVE_ALARM
/* Define to 1 if you have the <arpa/inet.h> header file. */
#undef HAVE_ARPA_INET_H
/* Define to 1 if libcurses is requested */
#undef HAVE_CURSES_H
/* Define to 1 if you don't have `vprintf' but do have `_doprnt.' */
#undef HAVE_DOPRNT
/* Define to 1 if you have the `erasechar' function. */
#undef HAVE_ERASECHAR
/* Define if ncurses has ESCDELAY variable */
#undef HAVE_ESCDELAY
/* Define to 1 if you have the <fcntl.h> header file. */
#undef HAVE_FCNTL_H
/* Define to 1 if you have the `fork' function. */
#undef HAVE_FORK
/* Define to 1 if you have the `getgid' function. */
#undef HAVE_GETGID
/* Define to 1 if you have the `getloadavg' function. */
#undef HAVE_GETLOADAVG
/* Define to 1 if you have the `getpass' function. */
#undef HAVE_GETPASS
/* Define to 1 if you have the `getpwuid' function. */
#undef HAVE_GETPWUID
/* Define to 1 if you have the `getuid' function. */
#undef HAVE_GETUID
/* Define to 1 if you have the <inttypes.h> header file. */
#undef HAVE_INTTYPES_H
/* Define to 1 if you have the `killchar' function. */
#undef HAVE_KILLCHAR
/* Define to 1 if you have the <limits.h> header file. */
#undef HAVE_LIMITS_H
/* Define to 1 if you have the `loadav' function. */
#undef HAVE_LOADAV
/* Define to 1 if `lstat' has the bug that it succeeds when given the
zero-length file name argument. */
#undef HAVE_LSTAT_EMPTY_STRING_BUG
/* Define to 1 if you have the <memory.h> header file. */
#undef HAVE_MEMORY_H
/* Define to 1 if you have the `memset' function. */
#undef HAVE_MEMSET
/* Define to 1 if libncurses is requested */
#undef HAVE_NCURSES_H
/* Define to 1 if you have the <ncurses/term.h> header file. */
#undef HAVE_NCURSES_TERM_H
/* Define to 1 if you have the `nlist' function. */
#undef HAVE_NLIST
/* Define to 1 if you have the <nlist.h> header file. */
#undef HAVE_NLIST_H
/* Define to 1 if you have the <process.h> header file. */
#undef HAVE_PROCESS_H
/* Define to 1 if you have the <pwd.h> header file. */
#undef HAVE_PWD_H
/* Define to 1 if you have the `setenv' function. */
#undef HAVE_SETENV
/* Define to 1 if you have the `setgid' function. */
#undef HAVE_SETGID
/* Define to 1 if you have the `setregid' function. */
#undef HAVE_SETREGID
/* Define to 1 if you have the `setresgid' function. */
#undef HAVE_SETRESGID
/* Define to 1 if you have the `setresuid' function. */
#undef HAVE_SETRESUID
/* Define to 1 if you have the `setreuid' function. */
#undef HAVE_SETREUID
/* Define to 1 if you have the `setuid' function. */
#undef HAVE_SETUID
/* Define to 1 if you have the `spawnl' function. */
#undef HAVE_SPAWNL
/* Define to 1 if `stat' has the bug that it succeeds when given the
zero-length file name argument. */
#undef HAVE_STAT_EMPTY_STRING_BUG
/* Define to 1 if stdbool.h conforms to C99. */
#undef HAVE_STDBOOL_H
/* Define to 1 if you have the <stdint.h> header file. */
#undef HAVE_STDINT_H
/* Define to 1 if you have the <stdlib.h> header file. */
#undef HAVE_STDLIB_H
/* Define to 1 if you have the `strchr' function. */
#undef HAVE_STRCHR
/* Define to 1 if you have the `strerror' function. */
#undef HAVE_STRERROR
/* Define to 1 if you have the <strings.h> header file. */
#undef HAVE_STRINGS_H
/* Define to 1 if you have the <string.h> header file. */
#undef HAVE_STRING_H
/* Define to 1 if you have the <sys/ioctl.h> header file. */
#undef HAVE_SYS_IOCTL_H
/* Define to 1 if you have the <sys/stat.h> header file. */
#undef HAVE_SYS_STAT_H
/* Define to 1 if you have the <sys/types.h> header file. */
#undef HAVE_SYS_TYPES_H
/* Define to 1 if you have the <sys/utsname.h> header file. */
#undef HAVE_SYS_UTSNAME_H
/* Define to 1 if you have the <termios.h> header file. */
#undef HAVE_TERMIOS_H
/* Define to 1 if you have the <term.h> header file. */
#undef HAVE_TERM_H
/* Define to 1 if you have the <unistd.h> header file. */
#undef HAVE_UNISTD_H
/* Define to 1 if you have the <utmp.h> header file. */
#undef HAVE_UTMP_H
/* Define to 1 if you have the `vfork' function. */
#undef HAVE_VFORK
/* Define to 1 if you have the <vfork.h> header file. */
#undef HAVE_VFORK_H
/* Define to 1 if you have the `vprintf' function. */
#undef HAVE_VPRINTF
/* Define to 1 if `fork' works. */
#undef HAVE_WORKING_FORK
/* Define to 1 if `vfork' works. */
#undef HAVE_WORKING_VFORK
/* Define to 1 if the system has the type `_Bool'. */
#undef HAVE__BOOL
/* Define to 1 if you have the `_spawnl' function. */
#undef HAVE__SPAWNL
/* define if we should use program's load average function instead of system
*/
#undef LOADAV
/* Define to file to use for scoreboard lockfile */
#undef LOCKFILE
/* Define to 1 if `lstat' dereferences a symlink specified with a trailing
slash. */
#undef LSTAT_FOLLOWS_SLASHED_SYMLINK
/* Define to include wizard mode */
#undef MASTER
/* Define if maxusers feature should be enabled */
#undef MAXLOAD
/* Define if maxusers feature should be enabled */
#undef MAXUSERS
/* kernel file to pass to nlist() when reading load average (unlikely to work)
*/
#undef NAMELIST
/* word for the number of scores to store in scoreboard */
#undef NUMNAME
/* number of scores to store in scoreboard */
#undef NUMSCORES
/* Define to the address where bug reports for this package should be sent. */
#undef PACKAGE_BUGREPORT
/* Define to the full name of this package. */
#undef PACKAGE_NAME
/* Define to the full name and version of this package. */
#undef PACKAGE_STRING
/* Define to the one symbol short name of this package. */
#undef PACKAGE_TARNAME
/* Define to the version of this package. */
#undef PACKAGE_VERSION
/* Define crypt(3) wizard mode password */
#undef PASSWD
/* Define as the return type of signal handlers (`int' or `void'). */
#undef RETSIGTYPE
/* Define to file to use for scoreboard */
#undef SCOREFILE
/* Define to 1 if you have the ANSI C header files. */
#undef STDC_HEADERS
/* Define to 1 if your <sys/time.h> declares `struct tm'. */
#undef TM_IN_SYS_TIME
/* define if we should use program's user counting function instead of
system's */
#undef UCOUNT
/* utmp like file to pass to ucount() when counting online users (unlikely to
work) */
#undef UTMP
/* Define to empty if `const' does not conform to ANSI C. */
#undef const
/* Define to `int' if <sys/types.h> doesn't define. */
#undef gid_t
/* Define to `int' if <sys/types.h> does not define. */
#undef pid_t
/* Define to `unsigned int' if <sys/types.h> does not define. */
#undef size_t
/* Define to `int' if <sys/types.h> doesn't define. */
#undef uid_t
/* Define as `fork' if `vfork' does not work. */
#undef vfork

1608
config.sub vendored

File diff suppressed because it is too large Load Diff

7511
configure vendored

File diff suppressed because it is too large Load Diff

View File

@@ -1,246 +0,0 @@
# -*- Autoconf -*-
# Process this file with autoconf to produce a configure script.
AC_PREREQ(2.56)
AC_INIT([Rogue],[5.4.4], [yendor@rogueforge.net])
AC_CONFIG_SRCDIR([armor.c])
AC_CONFIG_HEADER([config.h])
AC_CONFIG_FILES([Makefile rogue.6 rogue.cat rogue.doc rogue.html rogue.me])
AC_CANONICAL_SYSTEM([])
# Checks for programs.
AC_PROG_CC
# Checks for libraries.
# Checks for header files.
AC_HEADER_STDC
AC_CHECK_HEADERS([arpa/inet.h sys/utsname.h pwd.h fcntl.h limits.h nlist.h stdlib.h string.h sys/ioctl.h termios.h unistd.h utmp.h term.h ncurses/term.h process.h])
# Checks for typedefs, structures, and compiler characteristics.
AC_HEADER_STDBOOL
AC_C_CONST
AC_TYPE_UID_T
AC_TYPE_SIZE_T
AC_STRUCT_TM
MP_WITH_CURSES
# Checks for library functions.
AC_FUNC_FORK
AC_PROG_GCC_TRADITIONAL
AC_FUNC_LSTAT
AC_FUNC_LSTAT_FOLLOWS_SLASHED_SYMLINK
AC_TYPE_SIGNAL
AC_FUNC_STAT
AC_FUNC_VPRINTF
AC_CHECK_FUNCS([erasechar killchar alarm getpass memset setenv strchr nlist _spawnl spawnl getpwuid loadav getloadavg strerror setresgid setregid setgid setresuid setreuid setuid getuid getgid])
AC_CHECK_PROG([NROFF], [nroff], [nroff],)
AC_CHECK_PROG([GROFF], [groff], [groff],)
AC_CHECK_PROG([COLCRT], [colcrt], [colcrt],)
AC_CHECK_PROG([TBL], [tbl], [tbl],)
AC_CHECK_PROG([SED], [sed], [sed],)
AC_ARG_WITH(program-name, AC_HELP_STRING([--with-program-name=NAME],[alternate executable name]),[progname="$withval" ], [progname="rogue"] )
PROGRAM=$progname
AC_SUBST(PROGRAM)
AC_ARG_ENABLE(setgid, AC_HELP_STRING([--enable-setgid=NAME],[install executable as setgid with group ownership of NAME @<:@default=no@:>@])],[],[])
AC_MSG_CHECKING([if using setgid execute bit])
if test "x$enable_setgid" = "xno" ; then
GROUPOWNER=
elif test "x$enable_setgid" = "xyes" ; then
GROUPOWNER=games
elif test "x$enable_setgid" = "x" ; then
GROUPOWNER=
else
GROUPOWNER=$enable_setgid
fi
if test "x$GROUPOWNER" != "x" ; then
AC_DEFINE_UNQUOTED([GROUPOWNER],[$GROUPOWNER], [Define to group owner of setgid executable])
AC_MSG_RESULT([$GROUPOWNER])
else
AC_MSG_RESULT([no])
fi
AC_SUBST(GROUPOWNER)
AC_ARG_ENABLE([scorefile],[AC_HELP_STRING([--enable-scorefile=SCOREFILE], [enable scoreboard with given filename])],[],[])
AC_MSG_CHECKING([for scoreboard file])
if test "x$enable_scorefile" = "xno" ; then
SCOREFILE=
elif test "x$enable_scorefile" = "xyes" ; then
SCOREFILE=$progname.scr
elif test "x$enable_scorefile" = "x" ; then
SCOREFILE=$progname.scr
else
SCOREFILE=$enable_scorefile
fi
if test "x$SCOREFILE" != "x" ; then
AC_DEFINE_UNQUOTED([SCOREFILE], ["$SCOREFILE"], [Define to file to use for scoreboard])
AC_MSG_RESULT([$SCOREFILE])
else
AC_MSG_RESULT([disabled])
fi
AC_SUBST(SCOREFILE)
AC_ARG_ENABLE([lockfile],[AC_HELP_STRING([--enable-lockfile=LOCKFILE], [enable scoreboard lockfile with given filename])],[],[])
AC_MSG_CHECKING([for scoreboard lockfile file])
if test "x$enable_lockfile" = "xno" ; then
LOCKFILE=
elif test "x$enable_lockfile" = "xyes" ; then
LOCKFILE=$progname.lck
elif test "x$enable_lockfile" = "x" ; then
LOCKFILE=$progname.lck
else
LOCKFILE=$enable_lockfile
fi
if test "x$LOCKFILE" != "x" ; then
AC_DEFINE_UNQUOTED([LOCKFILE], ["$LOCKFILE"], [Define to file to use for scoreboard lockfile])
AC_MSG_RESULT([$LOCKFILE])
else
AC_MSG_RESULT([disabled])
fi
AC_SUBST(LOCKFILE)
AC_ARG_ENABLE([wizardmode],[AC_HELP_STRING([--enable-wizardmode], [enable availability of wizard mode @<:@default=no@:>@])],[],[])
AC_MSG_CHECKING([if wizard mode is enabled])
if test "x$enable_wizardmode" = "xno" ; then
AC_MSG_RESULT([no])
elif test "x$enable_wizardmode" = "x" ; then
AC_MSG_RESULT([no])
else
AC_DEFINE([MASTER], [], [Define to include wizard mode])
if test "x$enable_wizardmode" != "xyes" ; then
AC_DEFINE_UNQUOTED([PASSWD],[$enable_wizardmode], [Define crypt(3) wizard mode password])
fi
AC_MSG_RESULT([yes])
fi
AC_ARG_ENABLE([allscores],[AC_HELP_STRING([--enable-allscores], [enable scoreboard to show top scores, not just top players @<:@default=yes@:>@])],[],[enable_allscores=yes])
AC_MSG_CHECKING([if allscores is enabled])
if test "x$enable_allscores" = "xyes" ; then
AC_DEFINE([ALLSCORES], [1], [Define if scorefile is top scores, not top players])
AC_MSG_RESULT([yes])
else
AC_MSG_RESULT([no])
fi
AC_ARG_ENABLE([checktime],[AC_HELP_STRING([--enable-checktime], [enable checktime @<:@default=no@:>@])],[],[])
AC_MSG_CHECKING([if checktime is enabled])
if test "x$enable_checktime" = "xyes" ; then
AC_DEFINE([CHECKTIME], [1], [Define if checktime feature should be enabled])
AC_MSG_RESULT([yes])
else
AC_MSG_RESULT([no])
fi
AC_ARG_ENABLE([maxload],[AC_HELP_STRING([--enable-maxload], [enable maxload @<:@default=no@:>@])],[],[])
AC_MSG_CHECKING([runtime execution limit (maximum system load average)])
if test "x$enable_maxload" = "xyes" ; then
AC_DEFINE([MAXLOAD], [100], [Define if maxload feature should be enabled])
AC_MSG_RESULT([100])
elif test "x$enable_maxload" = "x" ; then
AC_MSG_RESULT([unlimited])
elif test "x$enable_maxload" = "xno" ; then
AC_MSG_RESULT([unlimited])
else
AC_DEFINE_UNQUOTED([MAXLOAD], [$enable_maxload], [Define if maxload feature should be enabled])
AC_MSG_RESULT([$enable_maxload])
fi
AC_ARG_ENABLE([maxusers],[AC_HELP_STRING([--enable-maxusers], [enable maxuser @<:@default=no@:>@])],[],[])
AC_MSG_CHECKING([runtime execution limit (maximum online system users)])
if test "x$enable_maxusers" = "xyes" ; then
AC_DEFINE([MAXUSERS], [100], [Define if maxusers feature should be enabled])
AC_MSG_RESULT([100])
elif test "x$enable_maxusers" = "x" ; then
AC_MSG_RESULT([unlimited])
elif test "x$enable_maxload" = "xno" ; then
AC_MSG_RESULT([unlimited])
else
AC_DEFINE_UNQUOTED([MAXLOAD], [$enable_maxusers], [Define if maxusers feature should be enabled])
AC_MSG_RESULT([$enable_maxusers])
fi
AC_ARG_ENABLE([numscores],[AC_HELP_STRING([--enable-numscores], [number of scores to store in scoreboard @<:@default=10@:>@])],[],[])
AC_MSG_CHECKING([what the number of scores to store in scoreboard is])
if test "x$numscores" = "xyes" ; then
AC_DEFINE([NUMSCORES], [10], [number of scores to store in scoreboard])
AC_MSG_RESULT([10])
elif test "x$enable_numscores" = "x" ; then
AC_DEFINE([NUMSCORES], [10], [number of scores to store in scoreboard])
AC_MSG_RESULT([10])
elif test "x$enable_numscores" = "xno" ; then
AC_DEFINE([NUMSCORES], [10], [number of scores to store in scoreboard])
AC_MSG_RESULT([10])
else
AC_DEFINE_UNQUOTED([NUMSCORES], [$enable_numscores], [number of scores to store in scoreboard])
AC_MSG_RESULT([$enable_numscores])
fi
AC_ARG_ENABLE([numname],[AC_HELP_STRING([--enable-numname], [word for number of scores to store in scoreboard @<:@default=Ten@:>@])],[],[])
AC_MSG_CHECKING([word for the number of scores to store in scoreboard is])
if test "x$enable_numname" = "xyes" ; then
AC_DEFINE([NUMNAME], ["Ten"], [word for the number of scores to store in scoreboard])
AC_MSG_RESULT([Ten])
elif test "x$enable_numname" = "x" ; then
AC_DEFINE([NUMNAME], ["Ten"], [word for the number of scores to store in scoreboard])
AC_MSG_RESULT([Ten])
elif test "x$enable_numname" = "xno" ; then
AC_DEFINE([NUMNAME], ["Ten"], [word for the number of scores to store in scoreboard])
AC_MSG_RESULT([Ten])
else
AC_DEFINE_UNQUOTED([NUMNAME], ["$enable_numname"], [word for the number of scores to store in scoreboard])
AC_MSG_RESULT([$enable_numname])
fi
AC_ARG_ENABLE([loadav],[AC_HELP_STRING([--enable-loadav=NAMELIST], [use program's load average function (unlikely to work) @<:@default=no@:>@])],[],[])
AC_MSG_CHECKING([whether to use program's built in load average function])
if test "x$enable_loadav" = "xyes" ; then
AC_DEFINE([LOADAV], [], [define if we should use program's load average function instead of system])
AC_DEFINE([NAMELIST], [/vmunix], [kernel file to pass to nlist() when reading load average (unlikely to work)])
AC_MSG_RESULT([/vmunix])
elif test "x$enable_loadav" = "x" ; then
AC_MSG_RESULT([no])
elif test "x$enable_loadav" = "xno" ; then
AC_MSG_RESULT([no])
else
AC_DEFINE([LOADAV], [], [define if we should use program's load average function instead of system])
AC_DEFINE_UNQUOTED([NAMELIST], [$enable_loadav], [kernel file to pass to nlist() when reading load average (unlikely to work)])
AC_MSG_RESULT([$enable_loadav])
fi
AC_ARG_ENABLE([ucount],[AC_HELP_STRING([--enable-ucount=UTMPFILE], [use program's own function to count users (unlikely to work) @<:@default=no@:>@])],[],[])
AC_MSG_CHECKING([whether to use program's built in user counting function])
if test "x$enable_ucount" = "xyes" ; then
AC_DEFINE([UCOUNT], [], [define if we should use program's user counting function instead of system's])
AC_DEFINE([UTMP], [/etc/utmp], [utmp like file to pass to ucount() when counting online users (unlikely to work)])
AC_MSG_RESULT([/etc/utmp])
elif test "x$enable_ucount" = "x" ; then
AC_MSG_RESULT([no])
elif test "x$enable_count" = "xno" ; then
AC_MSG_RESULT([no])
else
AC_DEFINE([UCOUNT], [], [define if we should use program's user counting function instead of system's])
AC_DEFINE_UNQUOTED([UTMP], [$enable_ucount], [utmp like file to pass to ucount() when counting online users (unlikely to work)])
AC_MSG_RESULT([$enable_ucount])
fi
TARGET=$target
AC_SUBST(TARGET)
AC_MSG_CHECKING([whether to docdir is defined])
if test "x$docdir" = "x" ; then
AC_MSG_RESULT([docdir undefined])
docdir=\${datadir}/doc/\${PACKAGE_TARNAME}
AC_SUBST(docdir)
else
AC_MSG_RESULT([docdir defined])
fi
AC_OUTPUT

181
daemon.c
View File

@@ -1,181 +0,0 @@
/*
* Contains functions for dealing with things that happen in the
* future.
*
* @(#)daemon.c 4.7 (Berkeley) 02/05/99
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <curses.h>
#include "rogue.h"
#define EMPTY 0
#define DAEMON -1
#define MAXDAEMONS 20
#define _X_ { EMPTY }
struct delayed_action d_list[MAXDAEMONS] = {
_X_, _X_, _X_, _X_, _X_, _X_, _X_, _X_, _X_, _X_,
_X_, _X_, _X_, _X_, _X_, _X_, _X_, _X_, _X_, _X_,
};
/*
* d_slot:
* Find an empty slot in the daemon/fuse list
*/
struct delayed_action *
d_slot()
{
register struct delayed_action *dev;
for (dev = d_list; dev <= &d_list[MAXDAEMONS-1]; dev++)
if (dev->d_type == EMPTY)
return dev;
#ifdef MASTER
debug("Ran out of fuse slots");
#endif
return NULL;
}
/*
* find_slot:
* Find a particular slot in the table
*/
struct delayed_action *
find_slot(void (*func)())
{
register struct delayed_action *dev;
for (dev = d_list; dev <= &d_list[MAXDAEMONS-1]; dev++)
if (dev->d_type != EMPTY && func == dev->d_func)
return dev;
return NULL;
}
/*
* start_daemon:
* Start a daemon, takes a function.
*/
void
start_daemon(void (*func)(), int arg, int type)
{
register struct delayed_action *dev;
dev = d_slot();
dev->d_type = type;
dev->d_func = func;
dev->d_arg = arg;
dev->d_time = DAEMON;
}
/*
* kill_daemon:
* Remove a daemon from the list
*/
void
kill_daemon(void (*func)())
{
register struct delayed_action *dev;
if ((dev = find_slot(func)) == NULL)
return;
/*
* Take it out of the list
*/
dev->d_type = EMPTY;
}
/*
* do_daemons:
* Run all the daemons that are active with the current flag,
* passing the argument to the function.
*/
void
do_daemons(int flag)
{
register struct delayed_action *dev;
/*
* Loop through the devil list
*/
for (dev = d_list; dev <= &d_list[MAXDAEMONS-1]; dev++)
/*
* Executing each one, giving it the proper arguments
*/
if (dev->d_type == flag && dev->d_time == DAEMON)
(*dev->d_func)(dev->d_arg);
}
/*
* fuse:
* Start a fuse to go off in a certain number of turns
*/
void
fuse(void (*func)(), int arg, int time, int type)
{
register struct delayed_action *wire;
wire = d_slot();
wire->d_type = type;
wire->d_func = func;
wire->d_arg = arg;
wire->d_time = time;
}
/*
* lengthen:
* Increase the time until a fuse goes off
*/
void
lengthen(void (*func)(), int xtime)
{
register struct delayed_action *wire;
if ((wire = find_slot(func)) == NULL)
return;
wire->d_time += xtime;
}
/*
* extinguish:
* Put out a fuse
*/
void
extinguish(void (*func)())
{
register struct delayed_action *wire;
if ((wire = find_slot(func)) == NULL)
return;
wire->d_type = EMPTY;
}
/*
* do_fuses:
* Decrement counters and start needed fuses
*/
void
do_fuses(int flag)
{
register struct delayed_action *wire;
/*
* Step though the list
*/
for (wire = d_list; wire <= &d_list[MAXDAEMONS-1]; wire++)
/*
* Decrementing counters and starting things we want. We also need
* to remove the fuse from the list once it has gone off.
*/
if (flag == wire->d_type && wire->d_time > 0 && --wire->d_time == 0)
{
wire->d_type = EMPTY;
(*wire->d_func)(wire->d_arg);
}
}

295
daemons.c
View File

@@ -1,295 +0,0 @@
/*
* All the daemon and fuse functions are in here
*
* @(#)daemons.c 4.24 (Berkeley) 02/05/99
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <curses.h>
#include "rogue.h"
/*
* doctor:
* A healing daemon that restors hit points after rest
*/
void
doctor()
{
register int lv, ohp;
lv = pstats.s_lvl;
ohp = pstats.s_hpt;
quiet++;
if (lv < 8)
{
if (quiet + (lv << 1) > 20)
pstats.s_hpt++;
}
else
if (quiet >= 3)
pstats.s_hpt += rnd(lv - 7) + 1;
if (ISRING(LEFT, R_REGEN))
pstats.s_hpt++;
if (ISRING(RIGHT, R_REGEN))
pstats.s_hpt++;
if (ohp != pstats.s_hpt)
{
if (pstats.s_hpt > max_hp)
pstats.s_hpt = max_hp;
quiet = 0;
}
}
/*
* Swander:
* Called when it is time to start rolling for wandering monsters
*/
void
swander()
{
start_daemon(rollwand, 0, BEFORE);
}
/*
* rollwand:
* Called to roll to see if a wandering monster starts up
*/
int between = 0;
void
rollwand()
{
if (++between >= 4)
{
if (roll(1, 6) == 4)
{
wanderer();
kill_daemon(rollwand);
fuse(swander, 0, WANDERTIME, BEFORE);
}
between = 0;
}
}
/*
* unconfuse:
* Release the poor player from his confusion
*/
void
unconfuse()
{
player.t_flags &= ~ISHUH;
msg("you feel less %s now", choose_str("trippy", "confused"));
}
/*
* unsee:
* Turn off the ability to see invisible
*/
void
unsee()
{
register THING *th;
for (th = mlist; th != NULL; th = next(th))
if (on(*th, ISINVIS) && see_monst(th))
mvaddch(th->t_pos.y, th->t_pos.x, th->t_oldch);
player.t_flags &= ~CANSEE;
}
/*
* sight:
* He gets his sight back
*/
void
sight()
{
if (on(player, ISBLIND))
{
extinguish(sight);
player.t_flags &= ~ISBLIND;
if (!(proom->r_flags & ISGONE))
enter_room(&hero);
msg(choose_str("far out! Everything is all cosmic again",
"the veil of darkness lifts"));
}
}
/*
* nohaste:
* End the hasting
*/
void
nohaste()
{
player.t_flags &= ~ISHASTE;
msg("you feel yourself slowing down");
}
/*
* stomach:
* Digest the hero's food
*/
void
stomach()
{
register int oldfood;
int orig_hungry = hungry_state;
if (food_left <= 0)
{
if (food_left-- < -STARVETIME)
death('s');
/*
* the hero is fainting
*/
if (no_command || rnd(5) != 0)
return;
no_command += rnd(8) + 4;
hungry_state = 3;
if (!terse)
addmsg(choose_str("the munchies overpower your motor capabilities. ",
"you feel too weak from lack of food. "));
msg(choose_str("You freak out", "You faint"));
}
else
{
oldfood = food_left;
food_left -= ring_eat(LEFT) + ring_eat(RIGHT) + 1 - amulet;
if (food_left < MORETIME && oldfood >= MORETIME)
{
hungry_state = 2;
msg(choose_str("the munchies are interfering with your motor capabilites",
"you are starting to feel weak"));
}
else if (food_left < 2 * MORETIME && oldfood >= 2 * MORETIME)
{
hungry_state = 1;
if (terse)
msg(choose_str("getting the munchies", "getting hungry"));
else
msg(choose_str("you are getting the munchies",
"you are starting to get hungry"));
}
}
if (hungry_state != orig_hungry) {
player.t_flags &= ~ISRUN;
running = FALSE;
to_death = FALSE;
count = 0;
}
}
/*
* come_down:
* Take the hero down off her acid trip.
*/
void
come_down()
{
register THING *tp;
register bool seemonst;
if (!on(player, ISHALU))
return;
kill_daemon(visuals);
player.t_flags &= ~ISHALU;
if (on(player, ISBLIND))
return;
/*
* undo the things
*/
for (tp = lvl_obj; tp != NULL; tp = next(tp))
if (cansee(tp->o_pos.y, tp->o_pos.x))
mvaddch(tp->o_pos.y, tp->o_pos.x, tp->o_type);
/*
* undo the monsters
*/
seemonst = on(player, SEEMONST);
for (tp = mlist; tp != NULL; tp = next(tp))
{
move(tp->t_pos.y, tp->t_pos.x);
if (cansee(tp->t_pos.y, tp->t_pos.x))
if (!on(*tp, ISINVIS) || on(player, CANSEE))
addch(tp->t_disguise);
else
addch(chat(tp->t_pos.y, tp->t_pos.x));
else if (seemonst)
{
standout();
addch(tp->t_type);
standend();
}
}
msg("Everything looks SO boring now.");
}
/*
* visuals:
* change the characters for the player
*/
void
visuals()
{
register THING *tp;
register bool seemonst;
if (!after || (running && jump))
return;
/*
* change the things
*/
for (tp = lvl_obj; tp != NULL; tp = next(tp))
if (cansee(tp->o_pos.y, tp->o_pos.x))
mvaddch(tp->o_pos.y, tp->o_pos.x, rnd_thing());
/*
* change the stairs
*/
if (!seenstairs && cansee(stairs.y, stairs.x))
mvaddch(stairs.y, stairs.x, rnd_thing());
/*
* change the monsters
*/
seemonst = on(player, SEEMONST);
for (tp = mlist; tp != NULL; tp = next(tp))
{
move(tp->t_pos.y, tp->t_pos.x);
if (see_monst(tp))
{
if (tp->t_type == 'X' && tp->t_disguise != 'X')
addch(rnd_thing());
else
addch(rnd(26) + 'A');
}
else if (seemonst)
{
standout();
addch(rnd(26) + 'A');
standend();
}
}
}
/*
* land:
* Land from a levitation potion
*/
void
land()
{
player.t_flags &= ~ISLEVIT;
msg(choose_str("bummer! You've hit the ground",
"you float gently to the ground"));
}

391
extern.c
View File

@@ -1,391 +0,0 @@
/*
* global variable initializaton
*
* @(#)extern.c 4.82 (Berkeley) 02/05/99
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <curses.h>
#include "rogue.h"
bool after; /* True if we want after daemons */
bool again; /* Repeating the last command */
int noscore; /* Was a wizard sometime */
bool seenstairs; /* Have seen the stairs (for lsd) */
bool amulet = FALSE; /* He found the amulet */
bool door_stop = FALSE; /* Stop running when we pass a door */
bool fight_flush = FALSE; /* True if toilet input */
bool firstmove = FALSE; /* First move after setting door_stop */
bool got_ltc = FALSE; /* We have gotten the local tty chars */
bool has_hit = FALSE; /* Has a "hit" message pending in msg */
bool in_shell = FALSE; /* True if executing a shell */
bool inv_describe = TRUE; /* Say which way items are being used */
bool jump = FALSE; /* Show running as series of jumps */
bool kamikaze = FALSE; /* to_death really to DEATH */
bool lower_msg = FALSE; /* Messages should start w/lower case */
bool move_on = FALSE; /* Next move shouldn't pick up items */
bool msg_esc = FALSE; /* Check for ESC from msg's --More-- */
bool passgo = FALSE; /* Follow passages */
bool playing = TRUE; /* True until he quits */
bool q_comm = FALSE; /* Are we executing a 'Q' command? */
bool running = FALSE; /* True if player is running */
bool save_msg = TRUE; /* Remember last msg */
bool see_floor = TRUE; /* Show the lamp illuminated floor */
bool stat_msg = FALSE; /* Should status() print as a msg() */
bool terse = FALSE; /* True if we should be short */
bool to_death = FALSE; /* Fighting is to the death! */
bool tombstone = TRUE; /* Print out tombstone at end */
#ifdef MASTER
int wizard = FALSE; /* True if allows wizard commands */
#endif
bool pack_used[26] = { /* Is the character used in the pack? */
FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE,
FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE,
FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE
};
char dir_ch; /* Direction from last get_dir() call */
char file_name[MAXSTR]; /* Save file name */
char huh[MAXSTR]; /* The last message printed */
char *p_colors[MAXPOTIONS]; /* Colors of the potions */
char prbuf[2*MAXSTR]; /* buffer for sprintfs */
char *r_stones[MAXRINGS]; /* Stone settings of the rings */
char runch; /* Direction player is running */
char *s_names[MAXSCROLLS]; /* Names of the scrolls */
char take; /* Thing she is taking */
char whoami[MAXSTR]; /* Name of player */
char *ws_made[MAXSTICKS]; /* What sticks are made of */
char *ws_type[MAXSTICKS]; /* Is it a wand or a staff */
int orig_dsusp; /* Original dsusp char */
char fruit[MAXSTR] = /* Favorite fruit */
{ 's', 'l', 'i', 'm', 'e', '-', 'm', 'o', 'l', 'd', '\0' };
char home[MAXSTR] = { '\0' }; /* User's home directory */
char *inv_t_name[] = {
"Overwrite",
"Slow",
"Clear"
};
char l_last_comm = '\0'; /* Last last_comm */
char l_last_dir = '\0'; /* Last last_dir */
char last_comm = '\0'; /* Last command typed */
char last_dir = '\0'; /* Last direction given */
char *tr_name[] = { /* Names of the traps */
"a trapdoor",
"an arrow trap",
"a sleeping gas trap",
"a beartrap",
"a teleport trap",
"a poison dart trap",
"a rust trap",
"a mysterious trap"
};
int n_objs; /* # items listed in inventory() call */
int ntraps; /* Number of traps on this level */
int hungry_state = 0; /* How hungry is he */
int inpack = 0; /* Number of things in pack */
int inv_type = 0; /* Type of inventory to use */
int level = 1; /* What level she is on */
int max_hit; /* Max damage done to her in to_death */
int max_level; /* Deepest player has gone */
int mpos = 0; /* Where cursor is on top line */
int no_food = 0; /* Number of levels without food */
int a_class[MAXARMORS] = { /* Armor class for each armor type */
8, /* LEATHER */
7, /* RING_MAIL */
7, /* STUDDED_LEATHER */
6, /* SCALE_MAIL */
5, /* CHAIN_MAIL */
4, /* SPLINT_MAIL */
4, /* BANDED_MAIL */
3, /* PLATE_MAIL */
};
int count = 0; /* Number of times to repeat command */
FILE *scoreboard = NULL; /* File descriptor for score file */
int food_left; /* Amount of food in hero's stomach */
int lastscore = -1; /* Score before this turn */
int no_command = 0; /* Number of turns asleep */
int no_move = 0; /* Number of turns held in place */
int purse = 0; /* How much gold he has */
int quiet = 0; /* Number of quiet turns */
int vf_hit = 0; /* Number of time flytrap has hit */
int dnum; /* Dungeon number */
int seed; /* Random number seed */
int e_levels[] = {
10L,
20L,
40L,
80L,
160L,
320L,
640L,
1300L,
2600L,
5200L,
13000L,
26000L,
50000L,
100000L,
200000L,
400000L,
800000L,
2000000L,
4000000L,
8000000L,
0L
};
coord delta; /* Change indicated to get_dir() */
coord oldpos; /* Position before last look() call */
coord stairs; /* Location of staircase */
PLACE places[MAXLINES*MAXCOLS]; /* level map */
THING *cur_armor; /* What he is wearing */
THING *cur_ring[2]; /* Which rings are being worn */
THING *cur_weapon; /* Which weapon he is weilding */
THING *l_last_pick = NULL; /* Last last_pick */
THING *last_pick = NULL; /* Last object picked in get_item() */
THING *lvl_obj = NULL; /* List of objects on this level */
THING *mlist = NULL; /* List of monsters on the level */
THING player; /* His stats */
/* restart of game */
WINDOW *hw = NULL; /* used as a scratch window */
#define INIT_STATS { 16, 0, 1, 10, 12, "1x4", 12 }
struct stats max_stats = INIT_STATS; /* The maximum for the player */
struct room *oldrp; /* Roomin(&oldpos) */
struct room rooms[MAXROOMS]; /* One for each room -- A level */
struct room passages[MAXPASS] = /* One for each passage */
{
{ {0, 0}, {0, 0}, {0, 0}, 0, ISGONE|ISDARK, 0, {{0,0}} },
{ {0, 0}, {0, 0}, {0, 0}, 0, ISGONE|ISDARK, 0, {{0,0}} },
{ {0, 0}, {0, 0}, {0, 0}, 0, ISGONE|ISDARK, 0, {{0,0}} },
{ {0, 0}, {0, 0}, {0, 0}, 0, ISGONE|ISDARK, 0, {{0,0}} },
{ {0, 0}, {0, 0}, {0, 0}, 0, ISGONE|ISDARK, 0, {{0,0}} },
{ {0, 0}, {0, 0}, {0, 0}, 0, ISGONE|ISDARK, 0, {{0,0}} },
{ {0, 0}, {0, 0}, {0, 0}, 0, ISGONE|ISDARK, 0, {{0,0}} },
{ {0, 0}, {0, 0}, {0, 0}, 0, ISGONE|ISDARK, 0, {{0,0}} },
{ {0, 0}, {0, 0}, {0, 0}, 0, ISGONE|ISDARK, 0, {{0,0}} },
{ {0, 0}, {0, 0}, {0, 0}, 0, ISGONE|ISDARK, 0, {{0,0}} },
{ {0, 0}, {0, 0}, {0, 0}, 0, ISGONE|ISDARK, 0, {{0,0}} },
{ {0, 0}, {0, 0}, {0, 0}, 0, ISGONE|ISDARK, 0, {{0,0}} }
};
#define ___ 1
#define XX 10
struct monster monsters[26] =
{
/* Name CARRY FLAG str, exp, lvl, amr, hpt, dmg */
{ "aquator", 0, ISMEAN, { XX, 20, 5, 2, ___, "0x0/0x0" } },
{ "bat", 0, ISFLY, { XX, 1, 1, 3, ___, "1x2" } },
{ "centaur", 15, 0, { XX, 17, 4, 4, ___, "1x2/1x5/1x5" } },
{ "dragon", 100, ISMEAN, { XX,5000, 10, -1, ___, "1x8/1x8/3x10" } },
{ "emu", 0, ISMEAN, { XX, 2, 1, 7, ___, "1x2" } },
{ "venus flytrap", 0, ISMEAN, { XX, 80, 8, 3, ___, "%%%x0" } },
/* NOTE: the damage is %%% so that xstr won't merge this */
/* string with others, since it is written on in the program */
{ "griffin", 20, ISMEAN|ISFLY|ISREGEN, { XX,2000, 13, 2, ___, "4x3/3x5" } },
{ "hobgoblin", 0, ISMEAN, { XX, 3, 1, 5, ___, "1x8" } },
{ "ice monster", 0, 0, { XX, 5, 1, 9, ___, "0x0" } },
{ "jabberwock", 70, 0, { XX,3000, 15, 6, ___, "2x12/2x4" } },
{ "kestrel", 0, ISMEAN|ISFLY, { XX, 1, 1, 7, ___, "1x4" } },
{ "leprechaun", 0, 0, { XX, 10, 3, 8, ___, "1x1" } },
{ "medusa", 40, ISMEAN, { XX,200, 8, 2, ___, "3x4/3x4/2x5" } },
{ "nymph", 100, 0, { XX, 37, 3, 9, ___, "0x0" } },
{ "orc", 15, ISGREED,{ XX, 5, 1, 6, ___, "1x8" } },
{ "phantom", 0, ISINVIS,{ XX,120, 8, 3, ___, "4x4" } },
{ "quagga", 0, ISMEAN, { XX, 15, 3, 3, ___, "1x5/1x5" } },
{ "rattlesnake", 0, ISMEAN, { XX, 9, 2, 3, ___, "1x6" } },
{ "snake", 0, ISMEAN, { XX, 2, 1, 5, ___, "1x3" } },
{ "troll", 50, ISREGEN|ISMEAN,{ XX, 120, 6, 4, ___, "1x8/1x8/2x6" } },
{ "black unicorn", 0, ISMEAN, { XX,190, 7, -2, ___, "1x9/1x9/2x9" } },
{ "vampire", 20, ISREGEN|ISMEAN,{ XX,350, 8, 1, ___, "1x10" } },
{ "wraith", 0, 0, { XX, 55, 5, 4, ___, "1x6" } },
{ "xeroc", 30, 0, { XX,100, 7, 7, ___, "4x4" } },
{ "yeti", 30, 0, { XX, 50, 4, 6, ___, "1x6/1x6" } },
{ "zombie", 0, ISMEAN, { XX, 6, 2, 8, ___, "1x8" } }
};
#undef ___
#undef XX
struct obj_info things[NUMTHINGS] = {
{ 0, 26 }, /* potion */
{ 0, 36 }, /* scroll */
{ 0, 16 }, /* food */
{ 0, 7 }, /* weapon */
{ 0, 7 }, /* armor */
{ 0, 4 }, /* ring */
{ 0, 4 }, /* stick */
};
struct obj_info arm_info[MAXARMORS] = {
{ "leather armor", 20, 20, NULL, FALSE },
{ "ring mail", 15, 25, NULL, FALSE },
{ "studded leather armor", 15, 20, NULL, FALSE },
{ "scale mail", 13, 30, NULL, FALSE },
{ "chain mail", 12, 75, NULL, FALSE },
{ "splint mail", 10, 80, NULL, FALSE },
{ "banded mail", 10, 90, NULL, FALSE },
{ "plate mail", 5, 150, NULL, FALSE },
};
struct obj_info pot_info[MAXPOTIONS] = {
{ "confusion", 7, 5, NULL, FALSE },
{ "hallucination", 8, 5, NULL, FALSE },
{ "poison", 8, 5, NULL, FALSE },
{ "gain strength", 13, 150, NULL, FALSE },
{ "see invisible", 3, 100, NULL, FALSE },
{ "healing", 13, 130, NULL, FALSE },
{ "monster detection", 6, 130, NULL, FALSE },
{ "magic detection", 6, 105, NULL, FALSE },
{ "raise level", 2, 250, NULL, FALSE },
{ "extra healing", 5, 200, NULL, FALSE },
{ "haste self", 5, 190, NULL, FALSE },
{ "restore strength", 13, 130, NULL, FALSE },
{ "blindness", 5, 5, NULL, FALSE },
{ "levitation", 6, 75, NULL, FALSE },
};
struct obj_info ring_info[MAXRINGS] = {
{ "protection", 9, 400, NULL, FALSE },
{ "add strength", 9, 400, NULL, FALSE },
{ "sustain strength", 5, 280, NULL, FALSE },
{ "searching", 10, 420, NULL, FALSE },
{ "see invisible", 10, 310, NULL, FALSE },
{ "adornment", 1, 10, NULL, FALSE },
{ "aggravate monster", 10, 10, NULL, FALSE },
{ "dexterity", 8, 440, NULL, FALSE },
{ "increase damage", 8, 400, NULL, FALSE },
{ "regeneration", 4, 460, NULL, FALSE },
{ "slow digestion", 9, 240, NULL, FALSE },
{ "teleportation", 5, 30, NULL, FALSE },
{ "stealth", 7, 470, NULL, FALSE },
{ "maintain armor", 5, 380, NULL, FALSE },
};
struct obj_info scr_info[MAXSCROLLS] = {
{ "monster confusion", 7, 140, NULL, FALSE },
{ "magic mapping", 4, 150, NULL, FALSE },
{ "hold monster", 2, 180, NULL, FALSE },
{ "sleep", 3, 5, NULL, FALSE },
{ "enchant armor", 7, 160, NULL, FALSE },
{ "identify potion", 10, 80, NULL, FALSE },
{ "identify scroll", 10, 80, NULL, FALSE },
{ "identify weapon", 6, 80, NULL, FALSE },
{ "identify armor", 7, 100, NULL, FALSE },
{ "identify ring, wand or staff", 10, 115, NULL, FALSE },
{ "scare monster", 3, 200, NULL, FALSE },
{ "food detection", 2, 60, NULL, FALSE },
{ "teleportation", 5, 165, NULL, FALSE },
{ "enchant weapon", 8, 150, NULL, FALSE },
{ "create monster", 4, 75, NULL, FALSE },
{ "remove curse", 7, 105, NULL, FALSE },
{ "aggravate monsters", 3, 20, NULL, FALSE },
{ "protect armor", 2, 250, NULL, FALSE },
};
struct obj_info weap_info[MAXWEAPONS + 1] = {
{ "mace", 11, 8, NULL, FALSE },
{ "long sword", 11, 15, NULL, FALSE },
{ "short bow", 12, 15, NULL, FALSE },
{ "arrow", 12, 1, NULL, FALSE },
{ "dagger", 8, 3, NULL, FALSE },
{ "two handed sword", 10, 75, NULL, FALSE },
{ "dart", 12, 2, NULL, FALSE },
{ "shuriken", 12, 5, NULL, FALSE },
{ "spear", 12, 5, NULL, FALSE },
{ NULL, 0 }, /* DO NOT REMOVE: fake entry for dragon's breath */
};
struct obj_info ws_info[MAXSTICKS] = {
{ "light", 12, 250, NULL, FALSE },
{ "invisibility", 6, 5, NULL, FALSE },
{ "lightning", 3, 330, NULL, FALSE },
{ "fire", 3, 330, NULL, FALSE },
{ "cold", 3, 330, NULL, FALSE },
{ "polymorph", 15, 310, NULL, FALSE },
{ "magic missile", 10, 170, NULL, FALSE },
{ "haste monster", 10, 5, NULL, FALSE },
{ "slow monster", 11, 350, NULL, FALSE },
{ "drain life", 9, 300, NULL, FALSE },
{ "nothing", 1, 5, NULL, FALSE },
{ "teleport away", 6, 340, NULL, FALSE },
{ "teleport to", 6, 50, NULL, FALSE },
{ "cancellation", 5, 280, NULL, FALSE },
};
struct h_list helpstr[] = {
{'?', " prints help", TRUE},
{'/', " identify object", TRUE},
{'h', " left", TRUE},
{'j', " down", TRUE},
{'k', " up", TRUE},
{'l', " right", TRUE},
{'y', " up & left", TRUE},
{'u', " up & right", TRUE},
{'b', " down & left", TRUE},
{'n', " down & right", TRUE},
{'H', " run left", FALSE},
{'J', " run down", FALSE},
{'K', " run up", FALSE},
{'L', " run right", FALSE},
{'Y', " run up & left", FALSE},
{'U', " run up & right", FALSE},
{'B', " run down & left", FALSE},
{'N', " run down & right", FALSE},
{CTRL('H'), " run left until adjacent", FALSE},
{CTRL('J'), " run down until adjacent", FALSE},
{CTRL('K'), " run up until adjacent", FALSE},
{CTRL('L'), " run right until adjacent", FALSE},
{CTRL('Y'), " run up & left until adjacent", FALSE},
{CTRL('U'), " run up & right until adjacent", FALSE},
{CTRL('B'), " run down & left until adjacent", FALSE},
{CTRL('N'), " run down & right until adjacent", FALSE},
{'\0', " <SHIFT><dir>: run that way", TRUE},
{'\0', " <CTRL><dir>: run till adjacent", TRUE},
{'f', "<dir> fight till death or near death", TRUE},
{'t', "<dir> throw something", TRUE},
{'m', "<dir> move onto without picking up", TRUE},
{'z', "<dir> zap a wand in a direction", TRUE},
{'^', "<dir> identify trap type", TRUE},
{'s', " search for trap/secret door", TRUE},
{'>', " go down a staircase", TRUE},
{'<', " go up a staircase", TRUE},
{'.', " rest for a turn", TRUE},
{',', " pick something up", TRUE},
{'i', " inventory", TRUE},
{'I', " inventory single item", TRUE},
{'q', " quaff potion", TRUE},
{'r', " read scroll", TRUE},
{'e', " eat food", TRUE},
{'w', " wield a weapon", TRUE},
{'W', " wear armor", TRUE},
{'T', " take armor off", TRUE},
{'P', " put on ring", TRUE},
{'R', " remove ring", TRUE},
{'d', " drop object", TRUE},
{'c', " call object", TRUE},
{'a', " repeat last command", TRUE},
{')', " print current weapon", TRUE},
{']', " print current armor", TRUE},
{'=', " print current rings", TRUE},
{'@', " print current stats", TRUE},
{'D', " recall what's been discovered", TRUE},
{'o', " examine/set options", TRUE},
{CTRL('R'), " redraw screen", TRUE},
{CTRL('P'), " repeat last message", TRUE},
{ESCAPE, " cancel command", TRUE},
{'S', " save game", TRUE},
{'Q', " quit", TRUE},
{'!', " shell escape", TRUE},
{'F', "<dir> fight till either of you dies", TRUE},
{'v', " print version number", TRUE},
{0, NULL }
};

197
extern.h
View File

@@ -1,197 +0,0 @@
/*
* Defines for things used in mach_dep.c
*
* @(#)extern.h 4.35 (Berkeley) 02/05/99
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#ifdef HAVE_CONFIG_H
#ifdef PDCURSES
#undef HAVE_UNISTD_H
#undef HAVE_LIMITS_H
#undef HAVE_MEMORY_H
#undef HAVE_STRING_H
#endif
#include "config.h"
#elif defined(__DJGPP__)
#define HAVE_SYS_TYPES_H 1
#define HAVE_PROCESS_H 1
#define HAVE_PWD_H 1
#define HAVE_TERMIOS_H 1
#define HAVE_SETGID 1
#define HAVE_GETGID 1
#define HAVE_SETUID 1
#define HAVE_GETUID 1
#define HAVE_GETPASS 1
#define HAVE_SPAWNL 1
#define HAVE_ALARM 1
#define HAVE_ERASECHAR 1
#define HAVE_KILLCHAR 1
#elif defined(_WIN32)
#define HAVE_CURSES_H
#define HAVE_TERM_H
#define HAVE__SPAWNL
#define HAVE_SYS_TYPES_H
#define HAVE_PROCESS_H
#define HAVE_ERASECHAR 1
#define HAVE_KILLCHAR 1
#elif defined(__CYGWIN__)
#define HAVE_SYS_TYPES_H 1
#define HAVE_PWD_H 1
#define HAVE_PWD_H 1
#define HAVE_SYS_UTSNAME_H 1
#define HAVE_ARPA_INET_H 1
#define HAVE_UNISTD_H 1
#define HAVE_TERMIOS_H 1
#define HAVE_NCURSES_TERM_H 1
#define HAVE_ESCDELAY
#define HAVE_SETGID 1
#define HAVE_GETGID 1
#define HAVE_SETUID 1
#define HAVE_GETUID 1
#define HAVE_GETPASS 1
#define HAVE_GETPWUID 1
#define HAVE_WORKING_FORK 1
#define HAVE_ALARM 1
#define HAVE_SPAWNL 1
#define HAVE__SPAWNL 1
#define HAVE_ERASECHAR 1
#define HAVE_KILLCHAR 1
#else /* POSIX */
#define HAVE_SYS_TYPES_H 1
#define HAVE_PWD_H 1
#define HAVE_PWD_H 1
#define HAVE_SYS_UTSNAME_H 1
#define HAVE_ARPA_INET_H 1
#define HAVE_UNISTD_H 1
#define HAVE_TERMIOS_H 1
#define HAVE_TERM_H 1
#define HAVE_SETGID 1
#define HAVE_GETGID 1
#define HAVE_SETUID 1
#define HAVE_GETUID 1
#define HAVE_SETREUID 1
#define HAVE_SETREGID 1
#define HAVE_GETPASS 1
#define HAVE_GETPWUID 1
#define HAVE_WORKING_FORK 1
#define HAVE_ERASECHAR 1
#define HAVE_KILLCHAR 1
#ifndef _AIX
#define HAVE_GETLOADAVG 1
#endif
#define HAVE_ALARM 1
#endif
#ifdef __DJGPP__
#undef HAVE_GETPWUID /* DJGPP's limited version doesn't even work as documented */
#endif
/*
* Don't change the constants, since they are used for sizes in many
* places in the program.
*/
#include <stdlib.h>
#undef SIGTSTP
#define MAXSTR 1024 /* maximum length of strings */
#define MAXLINES 32 /* maximum number of screen lines used */
#define MAXCOLS 80 /* maximum number of screen columns used */
#define RN (((seed = seed*11109+13849) >> 16) & 0xffff)
#ifdef CTRL
#undef CTRL
#endif
#define CTRL(c) (c & 037)
/*
* Now all the global variables
*/
extern bool got_ltc, in_shell;
extern int wizard;
extern char fruit[], prbuf[], whoami[];
extern int orig_dsusp;
extern FILE *scoreboard;
/*
* Function types
*/
void auto_save(int);
void come_down();
void doctor();
void end_line();
void endit(int sig);
void fatal();
void getltchars();
void land();
void leave(int);
void my_exit();
void nohaste();
void playit();
void playltchars(void);
void print_disc(char);
void quit(int);
void resetltchars(void);
void rollwand();
void runners();
void set_order();
void sight();
void stomach();
void swander();
void tstp(int ignored);
void unconfuse();
void unsee();
void visuals();
char add_line(char *fmt, char *arg);
char *killname(char monst, bool doart);
char *nothing(char type);
char *type_name(int type);
#ifdef CHECKTIME
int checkout();
#endif
int md_chmod(char *filename, int mode);
char *md_crypt(char *key, char *salt);
int md_dsuspchar();
int md_erasechar();
char *md_gethomedir();
char *md_getusername();
int md_getuid();
char *md_getpass(char *prompt);
int md_getpid();
char *md_getrealname(int uid);
void md_init();
int md_killchar();
void md_normaluser();
void md_raw_standout();
void md_raw_standend();
int md_readchar();
int md_setdsuspchar(int c);
int md_shellescape();
void md_sleep(int s);
int md_suspchar();
int md_hasclreol();
int md_unlink(char *file);
int md_unlink_open_file(char *file, FILE *inf);
void md_tstpsignal();
void md_tstphold();
void md_tstpresume();
void md_ignoreallsignals();
void md_onsignal_autosave();
void md_onsignal_exit();
void md_onsignal_default();
int md_issymlink(char *sp);

686
fight.c
View File

@@ -1,686 +0,0 @@
/*
* All the fighting gets done here
*
* @(#)fight.c 4.67 (Berkeley) 09/06/83
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <stdlib.h>
#include <curses.h>
#include <string.h>
#include <ctype.h>
#include "rogue.h"
#define EQSTR(a, b) (strcmp(a, b) == 0)
char *h_names[] = { /* strings for hitting */
" scored an excellent hit on ",
" hit ",
" have injured ",
" swing and hit ",
" scored an excellent hit on ",
" hit ",
" has injured ",
" swings and hits "
};
char *m_names[] = { /* strings for missing */
" miss",
" swing and miss",
" barely miss",
" don't hit",
" misses",
" swings and misses",
" barely misses",
" doesn't hit",
};
/*
* adjustments to hit probabilities due to strength
*/
static int str_plus[] = {
-7, -6, -5, -4, -3, -2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1,
1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3,
};
/*
* adjustments to damage done due to strength
*/
static int add_dam[] = {
-7, -6, -5, -4, -3, -2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 3,
3, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6
};
/*
* fight:
* The player attacks the monster.
*/
int
fight(coord *mp, THING *weap, bool thrown)
{
register THING *tp;
register bool did_hit = TRUE;
register char *mname, ch;
/*
* Find the monster we want to fight
*/
#ifdef MASTER
if ((tp = moat(mp->y, mp->x)) == NULL)
debug("Fight what @ %d,%d", mp->y, mp->x);
#else
tp = moat(mp->y, mp->x);
#endif
/*
* Since we are fighting, things are not quiet so no healing takes
* place.
*/
count = 0;
quiet = 0;
runto(mp);
/*
* Let him know it was really a xeroc (if it was one).
*/
ch = '\0';
if (tp->t_type == 'X' && tp->t_disguise != 'X' && !on(player, ISBLIND))
{
tp->t_disguise = 'X';
if (on(player, ISHALU)) {
ch = (char)(rnd(26) + 'A');
mvaddch(tp->t_pos.y, tp->t_pos.x, ch);
}
msg(choose_str("heavy! That's a nasty critter!",
"wait! That's a xeroc!"));
if (!thrown)
return FALSE;
}
mname = set_mname(tp);
did_hit = FALSE;
has_hit = (terse && !to_death);
if (roll_em(&player, tp, weap, thrown))
{
did_hit = FALSE;
if (thrown)
thunk(weap, mname, terse);
else
hit((char *) NULL, mname, terse);
if (on(player, CANHUH))
{
did_hit = TRUE;
tp->t_flags |= ISHUH;
player.t_flags &= ~CANHUH;
endmsg();
has_hit = FALSE;
msg("your hands stop glowing %s", pick_color("red"));
}
if (tp->t_stats.s_hpt <= 0)
killed(tp, TRUE);
else if (did_hit && !on(player, ISBLIND))
msg("%s appears confused", mname);
did_hit = TRUE;
}
else
if (thrown)
bounce(weap, mname, terse);
else
miss((char *) NULL, mname, terse);
return did_hit;
}
/*
* attack:
* The monster attacks the player
*/
int
attack(THING *mp)
{
register char *mname;
register int oldhp;
/*
* Since this is an attack, stop running and any healing that was
* going on at the time.
*/
running = FALSE;
count = 0;
quiet = 0;
if (to_death && !on(*mp, ISTARGET))
{
to_death = FALSE;
kamikaze = FALSE;
}
if (mp->t_type == 'X' && mp->t_disguise != 'X' && !on(player, ISBLIND))
{
mp->t_disguise = 'X';
if (on(player, ISHALU))
mvaddch(mp->t_pos.y, mp->t_pos.x, rnd(26) + 'A');
}
mname = set_mname(mp);
oldhp = pstats.s_hpt;
if (roll_em(mp, &player, (THING *) NULL, FALSE))
{
if (mp->t_type != 'I')
{
if (has_hit)
addmsg(". ");
hit(mname, (char *) NULL, FALSE);
}
else
if (has_hit)
endmsg();
has_hit = FALSE;
if (pstats.s_hpt <= 0)
death(mp->t_type); /* Bye bye life ... */
else if (!kamikaze)
{
oldhp -= pstats.s_hpt;
if (oldhp > max_hit)
max_hit = oldhp;
if (pstats.s_hpt <= max_hit)
to_death = FALSE;
}
if (!on(*mp, ISCANC))
switch (mp->t_type)
{
case 'A':
/*
* If an aquator hits, you can lose armor class.
*/
rust_armor(cur_armor);
when 'I':
/*
* The ice monster freezes you
*/
player.t_flags &= ~ISRUN;
if (!no_command)
{
addmsg("you are frozen");
if (!terse)
addmsg(" by the %s", mname);
endmsg();
}
no_command += rnd(2) + 2;
if (no_command > BORE_LEVEL)
death('h');
when 'R':
/*
* Rattlesnakes have poisonous bites
*/
if (!save(VS_POISON))
{
if (!ISWEARING(R_SUSTSTR))
{
chg_str(-1);
if (!terse)
msg("you feel a bite in your leg and now feel weaker");
else
msg("a bite has weakened you");
}
else if (!to_death)
{
if (!terse)
msg("a bite momentarily weakens you");
else
msg("bite has no effect");
}
}
when 'W':
case 'V':
/*
* Wraiths might drain energy levels, and Vampires
* can steal max_hp
*/
if (rnd(100) < (mp->t_type == 'W' ? 15 : 30))
{
register int fewer;
if (mp->t_type == 'W')
{
if (pstats.s_exp == 0)
death('W'); /* All levels gone */
if (--pstats.s_lvl == 0)
{
pstats.s_exp = 0;
pstats.s_lvl = 1;
}
else
pstats.s_exp = e_levels[pstats.s_lvl-1]+1;
fewer = roll(1, 10);
}
else
fewer = roll(1, 3);
pstats.s_hpt -= fewer;
max_hp -= fewer;
if (pstats.s_hpt <= 0)
pstats.s_hpt = 1;
if (max_hp <= 0)
death(mp->t_type);
msg("you suddenly feel weaker");
}
when 'F':
/*
* Venus Flytrap stops the poor guy from moving
*/
player.t_flags |= ISHELD;
sprintf(monsters['F'-'A'].m_stats.s_dmg,"%dx1", ++vf_hit);
if (--pstats.s_hpt <= 0)
death('F');
when 'L':
{
/*
* Leperachaun steals some gold
*/
register int lastpurse;
lastpurse = purse;
purse -= GOLDCALC;
if (!save(VS_MAGIC))
purse -= GOLDCALC + GOLDCALC + GOLDCALC + GOLDCALC;
if (purse < 0)
purse = 0;
remove_mon(&mp->t_pos, mp, FALSE);
mp=NULL;
if (purse != lastpurse)
msg("your purse feels lighter");
}
when 'N':
{
register THING *obj, *steal;
register int nobj;
/*
* Nymph's steal a magic item, look through the pack
* and pick out one we like.
*/
steal = NULL;
for (nobj = 0, obj = pack; obj != NULL; obj = next(obj))
if (obj != cur_armor && obj != cur_weapon
&& obj != cur_ring[LEFT] && obj != cur_ring[RIGHT]
&& is_magic(obj) && rnd(++nobj) == 0)
steal = obj;
if (steal != NULL)
{
remove_mon(&mp->t_pos, moat(mp->t_pos.y, mp->t_pos.x), FALSE);
mp=NULL;
leave_pack(steal, FALSE, FALSE);
msg("she stole %s!", inv_name(steal, TRUE));
discard(steal);
}
}
otherwise:
break;
}
}
else if (mp->t_type != 'I')
{
if (has_hit)
{
addmsg(". ");
has_hit = FALSE;
}
if (mp->t_type == 'F')
{
pstats.s_hpt -= vf_hit;
if (pstats.s_hpt <= 0)
death(mp->t_type); /* Bye bye life ... */
}
miss(mname, (char *) NULL, FALSE);
}
if (fight_flush && !to_death)
flush_type();
count = 0;
status();
if (mp == NULL)
return(-1);
else
return(0);
}
/*
* set_mname:
* return the monster name for the given monster
*/
char *
set_mname(THING *tp)
{
int ch;
char *mname;
static char tbuf[MAXSTR] = { 't', 'h', 'e', ' ' };
if (!see_monst(tp) && !on(player, SEEMONST))
return (terse ? "it" : "something");
else if (on(player, ISHALU))
{
move(tp->t_pos.y, tp->t_pos.x);
ch = toascii(inch());
if (!isupper(ch))
ch = rnd(26);
else
ch -= 'A';
mname = monsters[ch].m_name;
}
else
mname = monsters[tp->t_type - 'A'].m_name;
strcpy(&tbuf[4], mname);
return tbuf;
}
/*
* swing:
* Returns true if the swing hits
*/
int
swing(int at_lvl, int op_arm, int wplus)
{
int res = rnd(20);
int need = (20 - at_lvl) - op_arm;
return (res + wplus >= need);
}
/*
* roll_em:
* Roll several attacks
*/
bool
roll_em(THING *thatt, THING *thdef, THING *weap, bool hurl)
{
register struct stats *att, *def;
register char *cp;
register int ndice, nsides, def_arm;
register bool did_hit = FALSE;
register int hplus;
register int dplus;
register int damage;
att = &thatt->t_stats;
def = &thdef->t_stats;
if (weap == NULL)
{
cp = att->s_dmg;
dplus = 0;
hplus = 0;
}
else
{
hplus = (weap == NULL ? 0 : weap->o_hplus);
dplus = (weap == NULL ? 0 : weap->o_dplus);
if (weap == cur_weapon)
{
if (ISRING(LEFT, R_ADDDAM))
dplus += cur_ring[LEFT]->o_arm;
else if (ISRING(LEFT, R_ADDHIT))
hplus += cur_ring[LEFT]->o_arm;
if (ISRING(RIGHT, R_ADDDAM))
dplus += cur_ring[RIGHT]->o_arm;
else if (ISRING(RIGHT, R_ADDHIT))
hplus += cur_ring[RIGHT]->o_arm;
}
cp = weap->o_damage;
if (hurl)
{
if ((weap->o_flags&ISMISL) && cur_weapon != NULL &&
cur_weapon->o_which == weap->o_launch)
{
cp = weap->o_hurldmg;
hplus += cur_weapon->o_hplus;
dplus += cur_weapon->o_dplus;
}
else if (weap->o_launch < 0)
cp = weap->o_hurldmg;
}
}
/*
* If the creature being attacked is not running (alseep or held)
* then the attacker gets a plus four bonus to hit.
*/
if (!on(*thdef, ISRUN))
hplus += 4;
def_arm = def->s_arm;
if (def == &pstats)
{
if (cur_armor != NULL)
def_arm = cur_armor->o_arm;
if (ISRING(LEFT, R_PROTECT))
def_arm -= cur_ring[LEFT]->o_arm;
if (ISRING(RIGHT, R_PROTECT))
def_arm -= cur_ring[RIGHT]->o_arm;
}
while(cp != NULL && *cp != '\0')
{
ndice = atoi(cp);
if ((cp = strchr(cp, 'x')) == NULL)
break;
nsides = atoi(++cp);
if (swing(att->s_lvl, def_arm, hplus + str_plus[att->s_str]))
{
int proll;
proll = roll(ndice, nsides);
#ifdef MASTER
if (ndice + nsides > 0 && proll <= 0)
debug("Damage for %dx%d came out %d, dplus = %d, add_dam = %d, def_arm = %d", ndice, nsides, proll, dplus, add_dam[att->s_str], def_arm);
#endif
damage = dplus + proll + add_dam[att->s_str];
def->s_hpt -= max(0, damage);
did_hit = TRUE;
}
if ((cp = strchr(cp, '/')) == NULL)
break;
cp++;
}
return did_hit;
}
/*
* prname:
* The print name of a combatant
*/
char *
prname(char *mname, bool upper)
{
static char tbuf[MAXSTR];
*tbuf = '\0';
if (mname == 0)
strcpy(tbuf, "you");
else
strcpy(tbuf, mname);
if (upper)
*tbuf = (char) toupper(*tbuf);
return tbuf;
}
/*
* thunk:
* A missile hits a monster
*/
void
thunk(THING *weap, char *mname, bool noend)
{
if (to_death)
return;
if (weap->o_type == WEAPON)
addmsg("the %s hits ", weap_info[weap->o_which].oi_name);
else
addmsg("you hit ");
addmsg("%s", mname);
if (!noend)
endmsg();
}
/*
* hit:
* Print a message to indicate a succesful hit
*/
void
hit(char *er, char *ee, bool noend)
{
int i;
char *s;
extern char *h_names[];
if (to_death)
return;
addmsg(prname(er, TRUE));
if (terse)
s = " hit";
else
{
i = rnd(4);
if (er != NULL)
i += 4;
s = h_names[i];
}
addmsg(s);
if (!terse)
addmsg(prname(ee, FALSE));
if (!noend)
endmsg();
}
/*
* miss:
* Print a message to indicate a poor swing
*/
void
miss(char *er, char *ee, bool noend)
{
int i;
extern char *m_names[];
if (to_death)
return;
addmsg(prname(er, TRUE));
if (terse)
i = 0;
else
i = rnd(4);
if (er != NULL)
i += 4;
addmsg(m_names[i]);
if (!terse)
addmsg(" %s", prname(ee, FALSE));
if (!noend)
endmsg();
}
/*
* bounce:
* A missile misses a monster
*/
void
bounce(THING *weap, char *mname, bool noend)
{
if (to_death)
return;
if (weap->o_type == WEAPON)
addmsg("the %s misses ", weap_info[weap->o_which].oi_name);
else
addmsg("you missed ");
addmsg(mname);
if (!noend)
endmsg();
}
/*
* remove_mon:
* Remove a monster from the screen
*/
void
remove_mon(coord *mp, THING *tp, bool waskill)
{
register THING *obj, *nexti;
for (obj = tp->t_pack; obj != NULL; obj = nexti)
{
nexti = next(obj);
obj->o_pos = tp->t_pos;
detach(tp->t_pack, obj);
if (waskill)
fall(obj, FALSE);
else
discard(obj);
}
moat(mp->y, mp->x) = NULL;
mvaddch(mp->y, mp->x, tp->t_oldch);
detach(mlist, tp);
if (on(*tp, ISTARGET))
{
kamikaze = FALSE;
to_death = FALSE;
if (fight_flush)
flush_type();
}
discard(tp);
}
/*
* killed:
* Called to put a monster to death
*/
void
killed(THING *tp, bool pr)
{
char *mname;
pstats.s_exp += tp->t_stats.s_exp;
/*
* If the monster was a venus flytrap, un-hold him
*/
switch (tp->t_type)
{
case 'F':
player.t_flags &= ~ISHELD;
vf_hit = 0;
strcpy(monsters['F'-'A'].m_stats.s_dmg, "000x0");
when 'L':
{
THING *gold;
if (fallpos(&tp->t_pos, &tp->t_room->r_gold) && level >= max_level)
{
gold = new_item();
gold->o_type = GOLD;
gold->o_goldval = GOLDCALC;
if (save(VS_MAGIC))
gold->o_goldval += GOLDCALC + GOLDCALC
+ GOLDCALC + GOLDCALC;
attach(tp->t_pack, gold);
}
}
}
/*
* Get rid of the monster.
*/
mname = set_mname(tp);
remove_mon(&tp->t_pos, tp, TRUE);
if (pr)
{
if (has_hit)
{
addmsg(". Defeated ");
has_hit = FALSE;
}
else
{
if (!terse)
addmsg("you have ");
addmsg("defeated ");
}
msg(mname);
}
/*
* Do adjustments if he went up a level
*/
check_level();
if (fight_flush)
flush_type();
}

82
game/armor.go Normal file
View File

@@ -0,0 +1,82 @@
package game
// armor.c — misc functions for dealing with armor.
// wear lets the player put armor on (armor.c wear).
func (g *RogueGame) wear() {
p := &g.Player
obj, ok := g.promptPackItem("wear", KindArmor)
if !ok {
return
}
if p.CurArmor != nil {
g.addmsgf("you are already wearing some")
if !g.Options.Terse {
g.addmsgf(". You'll have to take it off first")
}
g.endmsg()
g.After = false
return
}
if obj.Kind != KindArmor {
g.msg("you can't wear that")
return
}
g.wasteTime()
obj.Flags.Set(Known)
sp := g.inventoryName(obj, true)
p.CurArmor = obj
if !g.Options.Terse {
g.addmsgf("you are now ")
}
g.msg("wearing %s", sp)
}
// takeOff gets the armor off of the player's back (armor.c take_off).
func (g *RogueGame) takeOff() {
p := &g.Player
obj := p.CurArmor
if obj == nil {
g.After = false
if g.Options.Terse {
g.msg("not wearing armor")
} else {
g.msg("you aren't wearing any armor")
}
return
}
if !g.dropCheck(p.CurArmor) {
return
}
p.CurArmor = nil
if g.Options.Terse {
g.addmsgf("was")
} else {
g.addmsgf("you used to be")
}
g.msg(" wearing %c) %s", obj.PackCh, g.inventoryName(obj, true))
}
// wasteTime does nothing but let other things happen (armor.c waste_time).
func (g *RogueGame) wasteTime() {
g.DoDaemons(Before)
g.DoFuses(Before)
g.DoDaemons(After)
g.DoFuses(After)
}

610
game/autosave_test.go Normal file
View File

@@ -0,0 +1,610 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
// Tests for the signal-triggered autosave handoff (issue #24): the signal
// goroutine must never encode game state itself, and the game goroutine
// must answer wherever it is parked.
import (
"io"
"os"
"path/filepath"
"testing"
"time"
)
// autoSaveWait is the deadline the tests hand AutoSaveOnSignal when they
// expect the save to be taken. It is long enough that a loaded machine
// cannot turn a working handoff into a spurious failure, and it is never
// actually waited out on a passing run. It is also what bounds
// driveUntilDone, by way of the saving goroutine it waits for — see
// there for what that bound comes to.
const autoSaveWait = 10 * time.Second
// TestAutoSaveOnSignalRacesTurnLoop is the test issue #24 exists for: it
// drives the real turn loop on one goroutine while another asks for a
// signal-triggered autosave over and over, which is the interleaving no
// test in the suite used to produce. `make test` runs with -race, so a
// save that encodes the live game tree from the asking goroutine — what
// the old AutoSave did straight from the signal handler — is reported as
// a data race and fails this test.
//
// Non-vacuity: with AutoSaveOnSignal's body replaced by a direct
// g.autoSave() call, i.e. exactly the pre-#24 behavior, this test fails
// under -race with the encoder reading state that command() is writing.
func TestAutoSaveOnSignalRacesTurnLoop(t *testing.T) {
t.Parallel()
// Same mix as TestTurnLoopCrashSweep — the spaces answer any --More--
// prompt — on a driveTerm, so the drive can run for as long as the
// saves take rather than for as long as a script lasts. The '.' and
// the 's' are what make an unbounded drive safe, and at least one of
// the two has to stay in the cycle: see driveTerm.
term := &driveTerm{script: []byte("h j k l y u b n s . ")}
g := New(Params{Seed: 20260809, Term: term})
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
g.startLevel()
g.prePlay()
const wantSaves = 25
var taken int
done := make(chan struct{})
go func() {
defer close(done)
for range wantSaves {
if g.AutoSaveOnSignal(autoSaveWait) {
taken++
}
}
}()
driveUntilDone(t, g, done)
// The close of done orders that goroutine's writes before this read.
if taken != wantSaves {
t.Errorf("saves taken = %d, want %d", taken, wantSaves)
}
// Every request was answered by the turn loop, so the file is the
// work of the game goroutine and must be a whole save.
assertRestorable(t, g.FileName)
}
// driveUntilDone runs turns until the saving goroutine is finished,
// fortifying the hero each turn so no death exits the test binary. The
// condition it waits on is that goroutine finishing — nothing else.
//
// It used to stop after a fixed 1000 turns and fail, and that cap was a
// load-sensitive assumption wearing a counter's clothes (issue #36). The
// turns this loop spends between one save request being answered and the
// next arriving are not work; they are the saving goroutine's scheduling
// latency, so the turn count 25 saves costs is a function of how
// contended the machine is rather than of anything the code under test
// does. Measured here on a 48-core host at load ~57: about 60-120 turns
// with a whole machine to spread over, 418 to 655 as GOMAXPROCS was cut
// from 4 to 1, and past 1000 under the doubled load of the verbose
// rerun, which is the flake this replaces. A budget that has to be
// guessed cannot be guessed right, so there is no budget.
//
// Dropping it costs no termination guarantee, because the bound belongs
// to the code under test and not to this loop: each AutoSaveOnSignal
// call returns within the timeout the caller hands it, so the saving
// goroutine always finishes and done always closes. That bound is worth
// stating exactly, because it is not one autoSaveWait.
//
// A handoff that has stopped answering altogether costs one, in total,
// however many saves were asked for. g.sigSave
// is one deep, so the unserviced request stays in the channel and every
// later call finds it full and reports failure immediately — measured
// at 10.0s for 25 saves with the service point deleted from command().
// What fails is then the caller's own assertion, the count of saves
// actually taken, which says far more than "out of turns" ever did.
//
// A handoff that still drains every request but takes longer than
// autoSaveWait to do it is the worst case, and costs one timeout per
// save: wantSaves * autoSaveWait, 250s at these constants, which would
// run past the package timeout rather than reach the assertion. It
// takes about ten seconds of scheduler starvation per save to get
// there, against a regime measured at 0.12s per 1000 turns, so it is
// remote — and the 1000-turn cap did not bound it either, a turn count
// being no kind of time bound. `go test -timeout 30s` is the backstop
// under all of it.
//
// The one thing the caller does have to supply is a terminal that can
// feed an unbounded drive: see driveTerm.
func driveUntilDone(t *testing.T, g *RogueGame, done <-chan struct{}) {
t.Helper()
for {
select {
case <-done:
return
default:
}
fortify(g)
g.command()
}
}
// TestAutoSaveOnSignalWhileBlockedOnInput is the case the fix is really
// for: the connection drops while the player is staring at the screen,
// so the game goroutine is parked in ReadChar and will not reach the
// between-turns check on its own. A flag checked only between turns would
// never be looked at here.
func TestAutoSaveOnSignalWhileBlockedOnInput(t *testing.T) {
t.Parallel()
bt := newBlockingTerm()
g := mkBlockedGame(t, bt)
read := make(chan byte)
go func() { read <- g.readchar() }()
// The wake is buffered, so this is correct whether or not the reader
// has reached ReadChar yet.
if !g.AutoSaveOnSignal(autoSaveWait) {
t.Fatal("the save was not taken while the game was blocked on input")
}
assertRestorable(t, g.FileName)
// The interrupt must not have been mistaken for a keystroke: the
// reader is still waiting, and still returns the real key.
bt.keys <- 'x'
if ch := <-read; ch != 'x' {
t.Errorf("readchar() = %q, want 'x'", ch)
}
}
// TestAutoSaveOnSignalWhileInShellEscape covers the other place the game
// goroutine parks for an unbounded time: the `!` shell escape, where it
// used to sit inside the shell call with no way to answer. A dropped line
// while the player is off in a shell is as much a hangup as any other.
func TestAutoSaveOnSignalWhileInShellEscape(t *testing.T) {
t.Parallel()
st := &shellTerm{
blockingTerm: newBlockingTerm(),
entered: make(chan struct{}),
release: make(chan struct{}),
}
g := mkBlockedGame(t, st)
left := make(chan struct{})
go func() {
defer close(left)
g.shell()
}()
<-st.entered
if !g.AutoSaveOnSignal(autoSaveWait) {
t.Fatal("the save was not taken while the game was in the shell escape")
}
assertRestorable(t, g.FileName)
close(st.release)
<-left
}
// TestShellEscapePanicUnwindsTheGameGoroutine pins the reason
// runShellEscape recovers its helper's panic.
//
// term.Tcell.ShellEscape panics when Screen.Resume fails, and the shell
// now runs on a helper goroutine. A panic reaching the top of that helper
// would kill the process without running the deferred calls of any other
// goroutine — including cmd/rogue/main.go's `defer t.Fini()`, which is
// the only thing that takes the tty back out of raw mode. That is issue
// #12's failure, and it would land on the one path where the terminal is
// already broken.
//
// So the panic has to arrive on the goroutine that runs the game, with
// that goroutine's deferred restore still on the stack. This test stands
// in for main: a Fini deferred around the g.shell() call, and the panic
// caught after it, asserting both that the restore ran and that the
// original value came through. Against the unrecovered version there is
// nothing to assert — the panic escapes a helper goroutine and takes the
// whole test binary down, which is the failure being prevented.
func TestShellEscapePanicUnwindsTheGameGoroutine(t *testing.T) {
t.Parallel()
pt := &panickingShellTerm{blockingTerm: newBlockingTerm()}
g := mkBlockedGame(t, pt)
caught := make(chan any, 1)
go func() {
// Registered first, so it runs last: it sees the terminal
// already restored, exactly as the runtime would have printed
// the trace after main's Fini.
defer func() { caught <- recover() }()
// Stands in for cmd/rogue/main.go's `defer t.Fini()`.
defer pt.Fini()
g.shell()
}()
got := <-caught
if got == nil {
t.Fatal("the resume failure did not reach the game goroutine")
}
if msg, ok := got.(string); !ok || msg != errShellResume {
t.Errorf("recovered %v, want %q", got, errShellResume)
}
if !pt.restored {
t.Error("the terminal was not restored on the way out")
}
// shell() must not have resumed into its InShell reset and refresh:
// there is no screen left to draw into.
if !g.InShell {
t.Error("shell() carried on drawing after the resume failed")
}
}
// TestAutoSaveOnSignalTimesOutLeavingTheOldSave pins the backstop: a game
// goroutine that never reaches a service point must not hold the process
// open, and giving up must cost the player nothing. The old save is still
// there, byte for byte — which is the whole point of renaming over the
// target instead of removing it first.
func TestAutoSaveOnSignalTimesOutLeavingTheOldSave(t *testing.T) {
t.Parallel()
g := mkGame(t, 77)
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
const old = "an older save nobody is allowed to destroy"
writeErr := os.WriteFile(g.FileName, []byte(old), 0o600)
if writeErr != nil {
t.Fatal(writeErr)
}
// Nothing drives the turn loop, so nothing will ever answer.
start := time.Now()
if g.AutoSaveOnSignal(100 * time.Millisecond) {
t.Error("AutoSaveOnSignal reported a save that nobody took")
}
if waited := time.Since(start); waited > time.Second {
t.Errorf("waited %v for an unanswered save, want the deadline to bound it",
waited)
}
got, readErr := os.ReadFile(g.FileName)
if readErr != nil {
t.Fatalf("the previous save was destroyed: %v", readErr)
}
if string(got) != old {
t.Error("the previous save was overwritten by a save that never ran")
}
}
// TestAutoSaveOnSignalWithoutASaveFile covers the death demo's terminal
// case: a game with no file name has nothing to write, and must say so
// rather than reporting a save that did not happen.
func TestAutoSaveOnSignalWithoutASaveFile(t *testing.T) {
t.Parallel()
g := New(Params{Seed: 5, Term: &driveTerm{script: []byte("s . ")}})
g.FileName = ""
g.startLevel()
g.prePlay()
var answered bool
done := make(chan struct{})
go func() {
defer close(done)
answered = g.AutoSaveOnSignal(autoSaveWait)
}()
driveUntilDone(t, g, done)
if answered {
t.Error("AutoSaveOnSignal = true with no save file name")
}
}
// TestSaveFileReplacesTargetAtomically pins the write discipline: the new
// save arrives by rename, so the file the player already had is never
// written into, and the temporary file it came from is not left lying in
// the save directory.
//
// The load-bearing assertion is the handle opened before the save. A
// rename leaves the old file whole and merely stops it being reachable by
// name, so that handle still reads the old save; the truncate-in-place
// write this replaced would empty it under the reader — the same
// in-place write that, interrupted, left the player with a file that
// could no longer be restored.
func TestSaveFileReplacesTargetAtomically(t *testing.T) {
t.Parallel()
g := mkGame(t, 11)
dir := t.TempDir()
path := filepath.Join(dir, "rogue.save")
const old = "an older save"
writeErr := os.WriteFile(path, []byte(old), 0o600)
if writeErr != nil {
t.Fatal(writeErr)
}
held, openErr := os.Open(path) //nolint:gosec // G304: test temp path
if openErr != nil {
t.Fatal(openErr)
}
defer func() { _ = held.Close() }()
saveErr := g.saveFile(path)
if saveErr != nil {
t.Fatalf("saveFile: %v", saveErr)
}
kept, readErr := io.ReadAll(held)
if readErr != nil {
t.Fatalf("reading the file that was there before the save: %v", readErr)
}
if string(kept) != old {
t.Errorf("the previous save was written into rather than replaced: %q",
string(kept))
}
entries, readErr := os.ReadDir(dir)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 1 || entries[0].Name() != "rogue.save" {
t.Errorf("save directory = %v, want just the save file", names(entries))
}
info, statErr := os.Stat(path)
if statErr != nil {
t.Fatal(statErr)
}
if perm := info.Mode().Perm(); perm != 0o400 {
t.Errorf("save file mode = %v, want 0400", perm)
}
assertRestorable(t, path)
}
// TestSaveFileLeavesTargetWhenTheRenameFails is the other half of the
// same discipline: a save that cannot be completed must leave what the
// player already had. The target here is a non-empty directory, which no
// rename can replace — the one write failure that can be forced without
// depending on file permissions, and therefore on not being root.
func TestSaveFileLeavesTargetWhenTheRenameFails(t *testing.T) {
t.Parallel()
g := mkGame(t, 12)
dir := t.TempDir()
path := filepath.Join(dir, "rogue.save")
mkErr := os.Mkdir(path, 0o700)
if mkErr != nil {
t.Fatal(mkErr)
}
keep := filepath.Join(path, "keep")
writeErr := os.WriteFile(keep, []byte("still here"), 0o600)
if writeErr != nil {
t.Fatal(writeErr)
}
saveErr := g.saveFile(path)
if saveErr == nil {
t.Error("saveFile over an unreplaceable target reported success")
}
_, statErr := os.Stat(keep)
if statErr != nil {
t.Errorf("the target was damaged by a failed save: %v", statErr)
}
entries, readErr := os.ReadDir(dir)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 1 {
t.Errorf("save directory = %v, want no temporary file left behind",
names(entries))
}
}
// names lists directory entry names for a failure message.
func names(entries []os.DirEntry) []string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.Name())
}
return out
}
// assertRestorable checks that path holds a save this program can load,
// which is what "the save was taken" has to mean: a file of the right
// size proves nothing about a torn encode.
func assertRestorable(t *testing.T, path string) {
t.Helper()
_, err := Restore(path, Params{Term: &testTerm{}})
if err != nil {
t.Errorf("the saved file does not restore: %v", err)
}
}
// mkBlockedGame builds a game with a save file name and a terminal whose
// reads block, for the tests that park the game goroutine.
func mkBlockedGame(t *testing.T, term Terminal) *RogueGame {
t.Helper()
g := New(Params{Seed: 4242, Term: term})
g.NewLevel()
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
return g
}
// driveTerm is a headless Terminal whose script repeats instead of
// running out, for the tests that drive the turn loop until something
// else finishes rather than for a set number of turns.
//
// testTerm cannot do that job. Once its script is exhausted it answers
// space and newline for ever, and neither takes a turn, so command() —
// which loops until the player does something that consumes one, the
// `if !g.After { ntimes++ }` in command.c — never returns. A drive with
// a turn cap sized to its script never notices; a drive that runs until
// the saves are taken wedges inside a single command() call, which is
// what a first attempt at issue #36 did.
//
// Repeating the script is necessary but nowhere near sufficient, and
// the difference is what anyone editing one of these scripts has to
// know. Most keys take a turn only conditionally. ' ' is the "legal
// illegal command" and clears After outright (tables.go). All eight
// movement keys clear it whenever the step is refused: a wall or the
// map edge (move.go moveResolve), an illegal diagonal (moveTarget), or
// a confused step that lands back in place (moveHero). A script of
// nothing but those keys wedges exactly the way testTerm's tail does,
// repetition or no repetition — with the script set to just " " this
// drive hits the 30s package timeout inside command().
//
// What actually makes the wedge impossible is that the cycle always
// contains at least one *unconditional* turn-taker, and the scripts
// here carry two: '.', the rest command, whose handler is empty, and
// 's', search, which writes After on no path. Nothing refuses either
// one — not being blocked in all eight directions, not Held, not stuck
// in a bear trap, and not NoCommand > 0, where playTurn skips
// executeCommand altogether and After is simply left true. Trim both
// out and the wedge this test exists to remove comes straight back.
//
// One further precondition, from what this fake does not supply:
// testTerm's tail answered a newline every other read and this does
// not. Nothing reachable from these scripts asks for one — waitFor('\n')
// sits on the death and score paths (rip.go, score.go), which fortify
// prevents from ever being reached — but a script that could reach them
// would park in waitFor for ever.
type driveTerm struct {
script []byte
pos int
}
func (t *driveTerm) Render(*Window) {}
func (t *driveTerm) Repaint() {}
func (t *driveTerm) Fini() {}
// Interrupt has nothing to wake: this terminal's ReadChar never blocks.
func (t *driveTerm) Interrupt() {}
// ReadChar hands out the next scripted key, wrapping at the end.
func (t *driveTerm) ReadChar() (byte, bool) {
ch := t.script[t.pos]
t.pos = (t.pos + 1) % len(t.script)
return ch, true
}
// blockingTerm is a Terminal that genuinely blocks in ReadChar until a
// key is pushed or Interrupt wakes it — which testTerm, whose reads never
// block, cannot reproduce.
type blockingTerm struct {
keys chan byte
wake chan struct{}
}
func newBlockingTerm() *blockingTerm {
return &blockingTerm{
keys: make(chan byte),
// Buffered by one and posted to without blocking, the same
// contract term.Tcell.Interrupt has with tcell's event queue: an
// interrupt that arrives before the read still wakes it.
wake: make(chan struct{}, 1),
}
}
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.
func (t *blockingTerm) Interrupt() {
select {
case t.wake <- struct{}{}:
default:
}
}
// ReadChar blocks until a key arrives or Interrupt wakes it.
func (t *blockingTerm) ReadChar() (byte, bool) {
select {
case ch := <-t.keys:
return ch, true
case <-t.wake:
return 0, false
}
}
// shellTerm is a blockingTerm that also offers a shell escape which stays
// in the shell until the test lets it out.
type shellTerm struct {
*blockingTerm
entered chan struct{}
release chan struct{}
}
// ShellEscape parks the caller in the "shell" until released.
func (t *shellTerm) ShellEscape() {
close(t.entered)
<-t.release
}
// errShellResume is what panickingShellTerm panics with, standing in for
// the value term.Tcell.ShellEscape raises when Screen.Resume fails.
const errShellResume = "resume failed"
// panickingShellTerm is a blockingTerm whose shell escape panics on the
// way out, the way term.Tcell.ShellEscape does when the screen cannot be
// resumed. It records whether Fini ran, which is the thing that must
// still happen.
type panickingShellTerm struct {
*blockingTerm
restored bool
}
func (t *panickingShellTerm) Fini() { t.restored = true }
func (t *panickingShellTerm) ShellEscape() { panic(errShellResume) }

497
game/bolt_test.go Normal file
View File

@@ -0,0 +1,497 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"slices"
"testing"
)
// The bolt geometry of sticks.c fire_bolt, tested on the hand-carved
// level built by mkCarvedGame in sticks_test.go.
//
// Most of these fire from a square that is not the hero's, which is what
// chase.c does when a dragon breathes (fire_bolt(&th->t_pos, ...)). That
// keeps the hero off the ray, so the run produces exactly one message
// and the screen stays readable as a record of where the bolt went — see
// litCells.
// The three names sticks.c fires a bolt under (do_zap's WS_ELECT,
// WS_FIRE and WS_COLD arms); fire_bolt prints them and hangs them on the
// FLAME weapon-table entry.
const (
boltName = "bolt"
flameName = "flame"
iceName = "ice"
)
// litCells reports every non-blank cell of the map area of the screen.
//
// fire_bolt paints its trail with dirch and then erases it by writing
// back chat() for each square it recorded, so on a screen nothing else
// has drawn on, the squares left non-blank are exactly the ones the bolt
// occupied. Squares it bounced off are absent by construction: C undoes
// the record with c1-- and breaks before the mvaddch, so a wall is
// neither painted nor erased.
func litCells(g *RogueGame) []Coord {
var out []Coord
// Row 0 is the message line, not the map.
for y := 1; y < NumLines; y++ {
line := g.scr.Std.Line(y)
for x := range len(line) {
if line[x] != ' ' {
out = append(out, Coord{X: x, Y: y})
}
}
}
return out
}
// assertErased checks that every square the bolt flew over is showing
// the map character underneath it again: fire_bolt's closing loop paints
// chat() back over the whole trail, so a bolt leaves no '/' or '\'
// behind.
func assertErased(t *testing.T, g *RogueGame, cells []Coord) {
t.Helper()
for _, c := range cells {
got := g.scr.Std.Line(c.Y)[c.X]
if want := g.Level.Char(c.Y, c.X); got != want {
t.Errorf("square %v shows %q, want the map's %q: the trail "+
"was not erased", c, got, want)
}
}
}
// TestBoltDirChar covers the dirch switch for all eight directions. C
// keys it on dir->y + dir->x: the two sums of zero are the '/' pair, the
// two of magnitude two are the '\' pair, and the four axis directions
// split on whether y is zero.
func TestBoltDirChar(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dir Coord
want byte
}{
{name: "north", dir: Coord{X: 0, Y: -1}, want: '|'},
{name: "south", dir: Coord{X: 0, Y: 1}, want: '|'},
{name: "east", dir: Coord{X: 1, Y: 0}, want: '-'},
{name: "west", dir: Coord{X: -1, Y: 0}, want: '-'},
{name: "north east", dir: Coord{X: 1, Y: -1}, want: '/'},
{name: "south west", dir: Coord{X: -1, Y: 1}, want: '/'},
{name: "north west", dir: Coord{X: -1, Y: -1}, want: '\\'},
{name: "south east", dir: Coord{X: 1, Y: 1}, want: '\\'},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := boltDirChar(tt.dir); got != tt.want {
t.Errorf("boltDirChar(%v) = %q, want %q", tt.dir, got, tt.want)
}
})
}
}
// TestBoltBounces covers the case labels a bolt reflects off, and the
// door exception: C jumps to the default arm when the hero is standing
// on the door, "otherwise it would loop infinitely".
func TestBoltBounces(t *testing.T) {
t.Parallel()
const heroX, heroY = 5, 5
tests := []struct {
name string
ch byte
pos Coord
want bool
}{
{name: "vertical wall", ch: '|', pos: Coord{X: 6, Y: 5}, want: true},
{name: "horizontal wall", ch: '-', pos: Coord{X: 6, Y: 5}, want: true},
{name: "solid rock", ch: ' ', pos: Coord{X: 6, Y: 5}, want: true},
{name: "door", ch: Door, pos: Coord{X: 6, Y: 5}, want: true},
{
name: "the door under the hero",
ch: Door,
pos: Coord{X: heroX, Y: heroY},
want: false,
},
{name: "floor", ch: Floor, pos: Coord{X: 6, Y: 5}, want: false},
{name: "passage", ch: Passage, pos: Coord{X: 6, Y: 5}, want: false},
{name: "staircase", ch: Stairs, pos: Coord{X: 6, Y: 5}, want: false},
{name: "a monster", ch: 'Z', pos: Coord{X: 6, Y: 5}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
hero := Coord{X: heroX, Y: heroY}
if got := boltBounces(tt.ch, hero, tt.pos); got != tt.want {
t.Errorf("boltBounces(%q) = %v, want %v", tt.ch, got, tt.want)
}
})
}
}
// TestFireBoltFliesStraight is the end-to-end run with nothing in the
// way: six squares, BOLT_LENGTH of them, and the last one is where the
// bolt stops.
func TestFireBoltFliesStraight(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 41)
dir := Coord{X: 1, Y: 0}
g.fireBolt(Coord{X: 2, Y: 2}, &dir, flameName)
want := []Coord{
{X: 3, Y: 2}, {X: 4, Y: 2}, {X: 5, Y: 2},
{X: 6, Y: 2}, {X: 7, Y: 2}, {X: 8, Y: 2},
}
got := litCells(g)
if !slices.Equal(got, want) {
t.Errorf("bolt path = %v, want %v", got, want)
}
assertErased(t, g, got)
if g.Msgs.Huh != "" {
t.Errorf("a bolt that hit nothing said %q", g.Msgs.Huh)
}
if (dir != Coord{X: 1, Y: 0}) {
t.Errorf("direction = %v, want it unchanged", dir)
}
}
// TestFireBoltBounces covers the reflection rule in both wall
// orientations, off a corner, and — the case that separates C's rule
// from a plausible wrong one — diagonally off a vertical wall. C negates
// *both* components, so a bolt that came in at 45 degrees goes back the
// way it came instead of reflecting off the surface.
// boltBounceCase is one wall-bounce run: where the bolt sets off, which
// way it goes, the wall it must reflect off, and the squares it must end
// up having occupied.
type boltBounceCase struct {
name string
start Coord
dir Coord
wall Coord
want []Coord
}
// run fires the case's bolt and checks its whole flight.
func (tt boltBounceCase) run(t *testing.T) {
t.Helper()
g := mkCarvedGame(t, 42)
dir := tt.dir
g.fireBolt(tt.start, &dir, flameName)
got := litCells(g)
if !slices.Equal(got, tt.want) {
t.Errorf("bolt path = %v, want %v", got, tt.want)
}
assertErased(t, g, got)
if slices.Contains(got, tt.wall) {
t.Errorf("the wall at %v was drawn on; C drops the bounce "+
"square from spotpos before the mvaddch", tt.wall)
}
if want := (Coord{X: -tt.dir.X, Y: -tt.dir.Y}); dir != want {
t.Errorf("direction = %v after one bounce, want %v", dir, want)
}
if g.Msgs.Huh != "the flame bounces" {
t.Errorf("message = %q, want %q", g.Msgs.Huh, "the flame bounces")
}
}
func TestFireBoltBounces(t *testing.T) {
t.Parallel()
tests := []boltBounceCase{
{
name: "off a vertical wall",
start: Coord{X: 3, Y: corridorY},
dir: Coord{X: -1, Y: 0},
wall: Coord{X: 1, Y: corridorY},
// Five squares, not six: the square in front of the wall is
// flown over twice, and C charges spotpos for both.
want: []Coord{
{X: 2, Y: 4}, {X: 3, Y: 4}, {X: 4, Y: 4},
{X: 5, Y: 4}, {X: 6, Y: 4},
},
},
{
name: "off a horizontal wall",
start: Coord{X: 5, Y: 2},
dir: Coord{X: 0, Y: -1},
wall: Coord{X: 5, Y: 1},
want: []Coord{
{X: 5, Y: 2}, {X: 5, Y: 3}, {X: 5, Y: 4},
{X: 5, Y: 5}, {X: 5, Y: 6}, {X: 5, Y: 7},
},
},
{
name: "off a corner",
start: Coord{X: 3, Y: 3},
dir: Coord{X: -1, Y: -1},
wall: Coord{X: 1, Y: 1},
want: []Coord{
{X: 2, Y: 2}, {X: 3, Y: 3}, {X: 4, Y: 4},
{X: 5, Y: 5}, {X: 6, Y: 6},
},
},
{
name: "diagonally off a vertical wall",
start: Coord{X: 3, Y: corridorY},
dir: Coord{X: -1, Y: -1},
wall: Coord{X: 1, Y: 2},
want: []Coord{
{X: 2, Y: 3}, {X: 3, Y: 4}, {X: 4, Y: 5},
{X: 5, Y: 6}, {X: 6, Y: 7},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tt.run(t)
})
}
}
// TestFireBoltReboundsIntoHero covers the hit_hero/changed pair: a bolt
// the hero fires starts unable to hit him, and the first bounce flips
// that, so a wall one square away throws his own bolt back at him.
func TestFireBoltReboundsIntoHero(t *testing.T) {
t.Parallel()
tests := []struct {
name string
lvl int
wantMsg string
wantHurt bool
}{
{
name: "the hero saves",
lvl: saveProofLvl,
wantMsg: "the flame whizzes by you",
wantHurt: false,
},
{
name: "the hero is hit",
lvl: 1,
wantMsg: "you are hit by the flame",
wantHurt: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 43)
placeHero(g, Coord{X: roomAX + 1, Y: corridorY})
fortify(g) // a bolt to the face must not exit the test binary
g.Player.Stats.Lvl = tt.lvl
pinRng(t, g, d20, 1) // the lowest save throw there is
hp := g.Player.Stats.HP
dir := Coord{X: -1, Y: 0}
g.fireBolt(g.Player.Pos, &dir, flameName)
if g.Msgs.Huh != tt.wantMsg {
t.Errorf("message = %q, want %q", g.Msgs.Huh, tt.wantMsg)
}
lost := hp - g.Player.Stats.HP
if hurt := lost > 0; hurt != tt.wantHurt {
t.Errorf("hero lost %d hit points, want hurt = %v",
lost, tt.wantHurt)
}
// roll(6, 6) is six to thirty-six.
if tt.wantHurt && (lost < 6 || lost > 36) {
t.Errorf("hero lost %d hit points, want 6..36", lost)
}
})
}
}
// TestFireBoltFromDoorUnderHeroTerminates covers the guard C wrote the
// door case for: the hero standing on a door and firing into the wall
// that door sits in. Without the ce(hero, pos) exception the bolt
// bounces on his own square forever, never recording a spot and never
// filling spotpos, and fire_bolt does not return — this test hangs
// rather than fails if the exception is lost.
func TestFireBoltFromDoorUnderHeroTerminates(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 44)
placeHero(g, Coord{X: doorAX, Y: corridorY})
fortify(g)
pinRng(t, g, d20, 1) // no save: the strike ends the flight
hp := g.Player.Stats.HP
dir := Coord{X: 0, Y: -1} // north, into the wall the door is in
g.fireBolt(g.Player.Pos, &dir, boltName)
if g.Msgs.Huh != "you are hit by the bolt" {
t.Errorf("message = %q, want the hero to be hit", g.Msgs.Huh)
}
if lost := hp - g.Player.Stats.HP; lost < 6 || lost > 36 {
t.Errorf("hero lost %d hit points, want 6..36", lost)
}
}
// TestFireBoltStrikesMonster covers the monster arm both ways, and the
// dragon's immunity to flame that C spells out in the same breath.
func TestFireBoltStrikesMonster(t *testing.T) {
t.Parallel()
tests := []struct {
name string
typ byte
lvl int
bolt string
wantMsg string
wantHurt bool
}{
{
name: "it fails its save",
typ: 'Z',
lvl: 1,
bolt: boltName,
wantMsg: "the bolt hits the zombie",
wantHurt: true,
},
{
name: "it saves",
typ: 'Z',
lvl: saveProofLvl,
bolt: boltName,
wantMsg: "the bolt whizzes past the zombie",
wantHurt: false,
},
{
name: "a dragon shrugs off a flame",
typ: 'D',
lvl: 1,
bolt: flameName,
wantMsg: "the flame bounces off the dragon",
wantHurt: false,
},
{
name: "but not a lightning bolt",
typ: 'D',
lvl: 1,
bolt: boltName,
wantMsg: "the bolt hits the dragon",
wantHurt: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 45)
placeHero(g, Coord{X: 5, Y: corridorY})
tp := putMonster(g, tt.typ, Coord{X: 8, Y: corridorY})
tp.Stats.Lvl = tt.lvl
tp.Stats.HP = 500 // enough to survive 6x6 and stay assertable
pinRng(t, g, d20, 1) // the lowest save throw there is
dir := Coord{X: 1, Y: 0}
g.fireBolt(g.Player.Pos, &dir, tt.bolt)
if g.Msgs.Huh != tt.wantMsg {
t.Errorf("message = %q, want %q", g.Msgs.Huh, tt.wantMsg)
}
if hurt := tp.Stats.HP < 500; hurt != tt.wantHurt {
t.Errorf("monster hit points = %d, want hurt = %v",
tp.Stats.HP, tt.wantHurt)
}
})
}
}
// TestFireBoltMissedMonsterWakesUp covers the rest of the miss arm: a
// bolt the hero fired sets the monster running (runto) before it says
// what it whizzed past, and the bolt flies on for its full length.
func TestFireBoltMissedMonsterWakesUp(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 46)
placeHero(g, Coord{X: 5, Y: corridorY})
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
tp.Stats.Lvl = saveProofLvl
tp.Flags.Clear(Awake)
dir := Coord{X: 1, Y: 0}
g.fireBolt(g.Player.Pos, &dir, boltName)
if !tp.On(Awake) {
t.Error("the monster the bolt missed is still asleep")
}
if tp.Dest != &g.Player.Pos {
t.Error("the woken monster is not chasing the hero")
}
if tp.OldCh != Floor {
t.Errorf("under-character = %q, want %q: fire_bolt records chat() "+
"before it resolves the save", tp.OldCh, Floor)
}
}
// TestFireBoltMissSpeaksEvenForAnM pins the "ch != 'M' ||
// tp->t_disguise == 'M'" guard on C's miss message, which reads as
// though something looking like an 'M' can be missed silently. It
// cannot: ch comes from winat, and winat *is* t_disguise whenever a
// monster stands there (rogue.h 57), so ch == 'M' implies
// t_disguise == 'M' and the condition is always true. The guard is
// vestigial — 'M' was the mimic in earlier Rogues — and a port that
// "tidied" it into a real silence would go quiet where C speaks.
func TestFireBoltMissSpeaksEvenForAnM(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 47)
placeHero(g, Coord{X: 5, Y: corridorY})
tp := putMonster(g, 'M', Coord{X: 8, Y: corridorY})
tp.Stats.Lvl = saveProofLvl
tp.Flags.Clear(Awake)
dir := Coord{X: 1, Y: 0}
g.fireBolt(g.Player.Pos, &dir, boltName)
const want = "the bolt whizzes past the medusa"
if g.Msgs.Huh != want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
}
if !tp.On(Awake) {
t.Error("the missed medusa was not set running")
}
}

522
game/chase.go Normal file
View File

@@ -0,0 +1,522 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// chase.c — code for one creature to chase another.
// dragonShot: one chance in DRAGONSHOT that a dragon will flame.
const dragonShot = 5
// runners makes all the running monsters move (chase.c runners).
func (g *RogueGame) runners(int) {
list := append([]*Monster(nil), g.Level.Monsters...)
for _, tp := range list {
g.runnerTurn(tp)
}
if g.HasHit {
g.endmsg()
g.HasHit = false
}
}
// runnerTurn gives one monster its motion for the turn; flying monsters
// far from the hero move twice (the loop body of chase.c runners).
func (g *RogueGame) runnerTurn(tp *Monster) {
if tp.On(Held) || !tp.On(Awake) {
return
}
origPos := tp.Pos
wastarget := tp.On(Targeted)
if removed := g.moveMonster(tp); removed {
return
}
if tp.On(Flying) && distCp(g.Player.Pos, tp.Pos) >= 3 {
if removed := g.moveMonster(tp); removed {
return
}
}
if wastarget && origPos != tp.Pos {
tp.Flags.Clear(Targeted)
g.ToDeath = false
}
}
// moveMonster executes a single turn of running for a monster (chase.c
// move_monst). The result reports that the monster died or left the
// level (the C -1 return).
func (g *RogueGame) moveMonster(tp *Monster) bool {
if !tp.On(Slowed) || tp.Turn {
if g.chaseStep(tp) {
return true
}
}
if tp.On(Hasted) {
if g.chaseStep(tp) {
return true
}
}
tp.Turn = !tp.Turn
return false
}
// relocate makes the monster's new location be the specified one, updating
// all the relevant state (chase.c relocate).
func (g *RogueGame) relocate(th *Monster, newLoc Coord) {
if newLoc != th.Pos {
g.mvaddch(th.Pos.Y, th.Pos.X, th.OldCh)
th.Room = g.roomIn(newLoc)
g.setOldChar(th, newLoc)
oroom := th.Room
g.Level.SetMonsterAt(th.Pos.Y, th.Pos.X, nil)
if oroom != th.Room {
th.Dest = g.findDest(th)
}
th.Pos = newLoc
g.Level.SetMonsterAt(newLoc.Y, newLoc.X, th)
}
g.move(newLoc.Y, newLoc.X)
if g.seeMonst(th) {
g.addch(th.Disguise)
} else if g.Player.On(SenseMonsters) {
g.standout()
g.addch(th.Type)
g.standend()
}
}
// chaseStep makes one thing chase another (chase.c do_chase). The
// result reports that the chaser died or left the level in the attempt
// (the C -1 return).
func (g *RogueGame) chaseStep(th *Monster) bool {
stoprun := false // true means we are there
rer, ree, door := g.chaseRooms(th)
this, shot := g.chaseGoal(th, rer, ree, door, 32767)
if shot {
return false
}
// This now contains what we want to run to this time so we run to it.
// If we hit it we either want to fight it or stop running
if g.chase(th, this) {
if th.Type == 'F' {
return false
}
} else {
switch this {
case g.Player.Pos:
return g.attack(th)
case *th.Dest:
g.chaseTakeObject(th)
stoprun = th.Type != 'F'
}
}
g.relocate(th, g.chRet)
// And stop running if need be
if stoprun && th.Pos == *th.Dest {
th.Flags.Clear(Awake)
}
return false
}
// chaseRooms finds the rooms of the chaser and its desire; doors do not
// count as inside rooms here (the setup of chase.c do_chase).
func (g *RogueGame) chaseRooms(th *Monster) (*Room, *Room, bool) {
p := &g.Player
rer := th.Room // find room of chaser
if th.On(Greedy) && rer.GoldVal == 0 {
th.Dest = &p.Pos // if gold has been taken, run after hero
}
var ree *Room // find room of chasee
if th.Dest == &p.Pos {
ree = p.Room
} else {
ree = g.roomIn(*th.Dest)
}
return rer, ree, g.Level.Char(th.Pos.Y, th.Pos.X) == Door
}
// chaseGoal picks the spot the chaser runs toward this turn: the
// nearest exit toward its desire when it is in a different room, or the
// desire itself. shot means a dragon breathed flame instead of moving
// (the goal loop of chase.c do_chase).
func (g *RogueGame) chaseGoal(
th *Monster, rer, ree *Room, door bool, mindist int,
) (Coord, bool) {
var this Coord
for {
// If the object of our desire is in a different room, and we are
// not in a corridor, run to the door nearest to our goal.
if rer == ree {
this = *th.Dest
return this, g.dragonBreath(th)
}
for i := range rer.Exits {
curdist := distCp(*th.Dest, rer.Exits[i])
if curdist < mindist {
this = rer.Exits[i]
mindist = curdist
}
}
if !door {
return this, false
}
rer = &g.Level.Passages[*g.Level.FlagsAt(th.Pos.Y, th.Pos.X)&FPassNum]
door = false
// the C goto over: redo with the passage as room
}
}
// dragonBreath checks whether a dragon shoots flame at the hero instead
// of moving, and shoots it (the D block of chase.c do_chase).
func (g *RogueGame) dragonBreath(th *Monster) bool {
if th.Type != 'D' || !g.dragonShoots(th) {
return false
}
p := &g.Player
g.Delta.Y = sign(p.Pos.Y - th.Pos.Y)
g.Delta.X = sign(p.Pos.X - th.Pos.X)
if g.HasHit {
g.endmsg()
}
g.fireBolt(th.Pos, &g.Delta, "flame")
g.Running = false
g.Count = 0
g.Quiet = 0
if g.ToDeath && !th.On(Targeted) {
g.ToDeath = false
g.Kamikaze = false
}
return true
}
// dragonShoots decides whether the dragon takes the shot: the hero is
// on a straight line from it, within shooting distance but outside
// striking range, it is not cancelled, and the shot roll comes up
// (chase.c do_chase).
func (g *RogueGame) dragonShoots(th *Monster) bool {
p := &g.Player
if th.Pos.Y != p.Pos.Y && th.Pos.X != p.Pos.X &&
abs(th.Pos.Y-p.Pos.Y) != abs(th.Pos.X-p.Pos.X) {
return false
}
return distCp(th.Pos, p.Pos) <= BoltLength*BoltLength &&
!th.On(Cancelled) && g.rnd(dragonShot) == 0
}
// chaseTakeObject has the monster pick up the object it was running to
// (the dest arm of chase.c do_chase).
func (g *RogueGame) chaseTakeObject(th *Monster) {
for _, obj := range g.Level.Objects {
if th.Dest == &obj.Pos {
g.Level.RemoveObject(obj)
attachObj(&th.Pack, obj)
if th.Room.Flags.Has(Gone) {
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Passage)
} else {
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Floor)
}
th.Dest = g.findDest(th)
break
}
}
}
// chase finds the spot for the chaser to move closer to the chasee
// (chase.c chase). Returns true if we want to keep on chasing later, false
// if we reach the goal. The chosen spot lands in g.chRet.
func (g *RogueGame) chase(tp *Monster, ee Coord) bool {
var curdist int
// If the thing is confused, let it move randomly. Invisible Stalkers
// are slightly confused all of the time, and bats are quite confused
// all the time
if (tp.On(Confused) && g.rnd(5) != 0) || (tp.Type == 'P' && g.rnd(5) == 0) ||
(tp.Type == 'B' && g.rnd(2) == 0) {
// get a valid random move
g.chRet = g.randomStep(&tp.Creature)
curdist = distCp(g.chRet, ee)
// Small chance that it will become un-confused
if g.rnd(20) == 0 {
tp.Flags.Clear(Confused)
}
} else {
curdist = g.chaseBestSpot(tp, ee)
}
return curdist != 0 && g.chRet != g.Player.Pos
}
// chaseSearch is the scan state while chase looks for the step that
// gets a monster closest to its chasee.
type chaseSearch struct {
er Coord // where the chaser is
ee Coord // where it wants to go
curdist int
plcnt int
}
// chaseBestSpot finds the empty spot next to the chaser that is closest
// to the chasee, leaving it in g.chRet; if there is none, the chaser
// stays where it is (the search half of chase.c chase).
func (g *RogueGame) chaseBestSpot(tp *Monster, ee Coord) int {
er := tp.Pos
s := chaseSearch{er: er, ee: ee, curdist: distCp(er, ee), plcnt: 1}
g.chRet = er
ey := er.Y + 1
if ey >= NumLines-1 {
ey = NumLines - 2
}
ex := er.X + 1
if ex >= NumCols {
ex = NumCols - 1
}
for x := er.X - 1; x <= ex; x++ {
if x < 0 {
continue
}
for y := er.Y - 1; y <= ey; y++ {
g.chaseTry(&s, y, x)
}
}
return s.curdist
}
// chaseTry scores one candidate square, reservoir-sampling among ties
// (the scan body of chase.c chase).
func (g *RogueGame) chaseTry(s *chaseSearch, y, x int) {
tryp := Coord{X: x, Y: y}
if !g.diagOk(s.er, tryp) {
return
}
ch := g.Level.VisibleChar(y, x)
if !stepOk(ch) {
return
}
// If it is a scroll, it might be a scare monster scroll so we need
// to look it up to see what type it is.
if ch == Scroll && g.scareScrollAt(y, x) {
return
}
// It can also be a Xeroc, which we shouldn't step on
if m := g.Level.MonsterAt(y, x); m != nil && m.Type == 'X' {
return
}
// If we didn't find any scrolls at this place or it wasn't a scare
// scroll, then this place counts
thisdist := distance(y, x, s.ee.Y, s.ee.X)
if thisdist < s.curdist {
s.plcnt = 1
g.chRet = tryp
s.curdist = thisdist
} else if thisdist == s.curdist {
if s.plcnt++; g.rnd(s.plcnt) == 0 {
g.chRet = tryp
s.curdist = thisdist
}
}
}
// scareScrollAt reports whether the object lying at (y, x) is a scare
// monster scroll (chase.c chase).
func (g *RogueGame) scareScrollAt(y, x int) bool {
for _, obj := range g.Level.Objects {
if y == obj.Pos.Y && x == obj.Pos.X {
return obj.ScrollKind() == ScrollScareMonster
}
}
return false
}
// setOldChar sets the oldch character for the monster (chase.c set_oldch).
func (g *RogueGame) setOldChar(tp *Monster, cp Coord) {
if tp.Pos == cp {
return
}
sch := tp.OldCh
tp.OldCh = g.mvinch(cp.Y, cp.X)
if !g.Player.On(Blind) {
if (sch == Floor || tp.OldCh == Floor) && tp.Room.Flags.Has(Dark) {
tp.OldCh = ' '
} else if distCp(cp, g.Player.Pos) <= LampDist && g.Options.SeeFloor {
tp.OldCh = g.Level.Char(cp.Y, cp.X)
}
}
}
// seeMonst returns true if the hero can see the monster (chase.c
// see_monst).
func (g *RogueGame) seeMonst(mp *Monster) bool {
p := &g.Player
if p.On(Blind) {
return false
}
if mp.On(Invisible) && !p.On(CanSeeInvisible) {
return false
}
y, x := mp.Pos.Y, mp.Pos.X
if distance(y, x, p.Pos.Y, p.Pos.X) < LampDist {
if y != p.Pos.Y && x != p.Pos.X &&
!stepOk(g.Level.Char(y, p.Pos.X)) && !stepOk(g.Level.Char(p.Pos.Y, x)) {
return false
}
return true
}
if mp.Room != p.Room {
return false
}
return !mp.Room.Flags.Has(Dark)
}
// runTo sets a monster running after the hero (chase.c runto).
func (g *RogueGame) runTo(runner Coord) {
tp := g.Level.MonsterAt(runner.Y, runner.X)
if tp == nil {
return
}
// Start the beastie running
tp.Flags.Set(Awake)
tp.Flags.Clear(Held)
tp.Dest = g.findDest(tp)
}
// roomIn finds what room some coordinates are in; nil means they aren't in
// any room (chase.c roomin).
func (g *RogueGame) roomIn(cp Coord) *Room {
fp := *g.Level.FlagsAt(cp.Y, cp.X)
if fp.Has(FPassage) {
return &g.Level.Passages[fp&FPassNum]
}
for i := range g.Level.Rooms {
rp := &g.Level.Rooms[i]
if cp.X <= rp.Pos.X+rp.Max.X && rp.Pos.X <= cp.X &&
cp.Y <= rp.Pos.Y+rp.Max.Y && rp.Pos.Y <= cp.Y {
return rp
}
}
g.msg("in some bizarre place (%d, %d)", cp.Y, cp.X)
return nil
}
// diagOk checks to see if a diagonal move is legal (chase.c diag_ok).
func (g *RogueGame) diagOk(sp, ep Coord) bool {
if ep.X < 0 || ep.X >= NumCols || ep.Y <= 0 || ep.Y >= NumLines-1 {
return false
}
if ep.X == sp.X || ep.Y == sp.Y {
return true
}
return stepOk(g.Level.Char(ep.Y, sp.X)) && stepOk(g.Level.Char(sp.Y, ep.X))
}
// canSee returns true if the hero can see a certain coordinate (chase.c
// cansee).
func (g *RogueGame) canSee(y, x int) bool {
p := &g.Player
if p.On(Blind) {
return false
}
if distance(y, x, p.Pos.Y, p.Pos.X) < LampDist {
if g.Level.FlagsAt(y, x).Has(FPassage) {
if y != p.Pos.Y && x != p.Pos.X &&
!stepOk(g.Level.Char(y, p.Pos.X)) &&
!stepOk(g.Level.Char(p.Pos.Y, x)) {
return false
}
}
return true
}
// We can only see if the hero is in the same room as the coordinate
// and the room is lit, or if it is close.
rer := g.roomIn(Coord{X: x, Y: y})
return rer == p.Room && !rer.Flags.Has(Dark)
}
// findDest finds the proper destination for the monster (chase.c
// find_dest).
func (g *RogueGame) findDest(tp *Monster) *Coord {
prob := g.Monsters[tp.Type-'A'].Carry
if prob <= 0 || tp.Room == g.Player.Room || g.seeMonst(tp) {
return &g.Player.Pos
}
for _, obj := range g.Level.Objects {
if obj.Kind == KindScroll && obj.ScrollKind() == ScrollScareMonster {
continue
}
if g.roomIn(obj.Pos) == tp.Room && g.rnd(100) < prob &&
!g.objectClaimed(obj) {
return &obj.Pos
}
}
return &g.Player.Pos
}
// objectClaimed reports whether some monster already runs toward this
// object (chase.c find_dest).
func (g *RogueGame) objectClaimed(obj *Object) bool {
for _, other := range g.Level.Monsters {
if other.Dest == &obj.Pos {
return true
}
}
return false
}

992
game/command.go Normal file
View File

@@ -0,0 +1,992 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// command.c — read and execute the user commands.
// command processes one player command, with its BEFORE/AFTER daemon
// bracketing (command.c command).
func (g *RogueGame) command() {
p := &g.Player
// Between turns is the one point in the loop where the game state is
// whole, so it is where a signal-triggered autosave is answered when
// the game goroutine is busy rather than waiting for a key (issue
// #24). The other service points are readchar (io.c) and
// runShellEscape, covering the two ways this goroutine can be parked.
g.serviceAutoSaveRequest()
ntimes := 1 // number of player moves
if p.On(Hasted) {
ntimes++
}
// Let the daemons start up
g.DoDaemons(Before)
g.DoFuses(Before)
for ; ntimes > 0; ntimes-- {
g.playTurn()
if !g.After {
ntimes++
}
}
g.DoDaemons(After)
g.DoFuses(After)
g.ringTurnEffects(Left)
g.ringTurnEffects(Right)
}
// ringTurnEffects fires the per-turn ring powers on one hand: searching
// searches, teleportation teleports on 1 turn in 50 (the tail of
// command.c command).
func (g *RogueGame) ringTurnEffects(hand int) {
p := &g.Player
if p.IsRing(hand, RingSearching) {
g.search()
} else if p.IsRing(hand, RingTeleportation) && g.rnd(50) == 0 {
g.teleport()
}
}
// playTurn runs one full player turn: upkeep, one command, and pickup
// (the body of the command.c command loop).
func (g *RogueGame) playTurn() {
p := &g.Player
g.turnUpkeep()
var ch byte
if g.NoCommand == 0 {
ch = g.readCommand()
} else {
ch = '.'
}
if g.NoCommand != 0 {
if g.NoCommand--; g.NoCommand == 0 {
p.Flags.Set(Awake)
g.msg("you can move again")
}
} else {
g.executeCommand(ch)
}
// If he ran into something to take, let him pick it up.
if g.Take != 0 {
g.pickUp(g.Take)
}
if !g.Running {
g.DoorStop = false
}
}
// turnUpkeep redraws and resets per-turn state before a command is read
// (the top of the command.c command loop).
func (g *RogueGame) turnUpkeep() {
p := &g.Player
g.Again = false
if g.HasHit {
g.endmsg()
g.HasHit = false
}
// these are illegal things for the player to be, so if any are
// set, someone's been poking in memory
if p.On(Slowed | Greedy | Invisible | Regenerates | Targeted) {
panic("player flags corrupted")
}
g.look(true)
if !g.Running {
g.DoorStop = false
}
g.status()
g.LastScore = p.Purse
g.move(p.Pos.Y, p.Pos.X)
if (!g.Running && g.Count == 0) || !g.Options.Jump {
g.refresh() // draw screen
}
g.Take = 0
g.After = true
// Read command or continue run
if g.Wizard {
g.NoScore = true
}
}
// readCommand picks the next command character: run/count repeats or
// fresh input (the read block of command.c command).
func (g *RogueGame) readCommand() byte {
switch {
case g.Running || g.ToDeath:
return g.RunCh
case g.Count != 0:
return g.countCh
default:
ch := g.readchar()
g.MoveOn = false
if g.Msgs.Mpos != 0 { // erase message if its there
g.msg("")
}
return ch
}
}
// executeCommand runs one read command with the repeat-count prefix and
// the repeat-history bookkeeping (the execute block of command.c
// command).
func (g *RogueGame) executeCommand(ch byte) {
// check for prefixes
g.newCount = false
ch = g.countPrefix(ch)
// execute a command
if g.Count != 0 && !g.Running {
g.Count--
}
if ch != 'a' && ch != Escape &&
!g.Running && g.Count == 0 && !g.ToDeath {
g.LLastComm = g.LastComm
g.LLastDir = g.LastDir
g.LLastPick = g.LastPick
g.LastComm = ch
g.LastDir = 0
g.LastPick = nil
}
g.dispatch(ch)
// turn off flags if no longer needed
if !g.Running {
g.DoorStop = false
}
}
// countPrefix consumes a numeric repeat-count prefix and returns the
// command character that follows it (command.c).
func (g *RogueGame) countPrefix(ch byte) byte {
if !isDigit(ch) {
return ch
}
g.Count = 0
g.newCount = true
for isDigit(ch) {
g.Count = min(g.Count*10+int(ch-'0'), 255)
ch = g.readchar()
}
g.countCh = ch
// turn off count for commands which don't make sense to repeat
switch ch {
case CTRL('B'), CTRL('H'), CTRL('J'), CTRL('K'),
CTRL('L'), CTRL('N'), CTRL('U'), CTRL('Y'),
'.', 'a', 'b', 'h', 'j', 'k', 'l', 'm', 'n', 'q',
'r', 's', 't', 'u', 'y', 'z', 'B', 'C', 'H', 'I',
'J', 'K', 'L', 'N', 'U', 'Y',
CTRL('D'), CTRL('A'):
default:
g.Count = 0
}
return ch
}
// dispatch runs one command character, re-running dispatchKey when it
// asks (the C `goto over` re-dispatch in command.c).
func (g *RogueGame) dispatch(ch byte) {
for {
newCh, again := g.dispatchKey(ch)
if !again {
return
}
ch = newCh
}
}
// dispatchKey runs one command character (the big switch of command.c).
// A true second result asks dispatch to run once more with the returned
// key.
func (g *RogueGame) dispatchKey(ch byte) (byte, bool) {
if h, ok := g.data.commandHandlers[ch]; ok {
h(g)
return 0, false
}
switch ch {
case CTRL('H'), CTRL('J'), CTRL('K'), CTRL('L'),
CTRL('Y'), CTRL('U'), CTRL('B'), CTRL('N'):
return g.runCommand(ch)
case 'F', 'f':
return g.fightCommand(ch)
case 'a':
return g.repeatCommand()
case 'm':
return g.moveOnCommand()
default:
g.After = false
if g.Wizard {
g.wizardCommand(ch)
} else {
g.illcom(ch)
}
}
return 0, false
}
// runCommand handles the ctrl-direction run-until-adjacent prefix; the
// returned key re-dispatches as the matching run command (command.c).
func (g *RogueGame) runCommand(ch byte) (byte, bool) {
if !g.Player.On(Blind) {
g.DoorStop = true
g.Firstmove = true
}
if g.Count != 0 && !g.newCount {
ch = g.direction
} else {
ch += 'A' - CTRL('A')
g.direction = ch
}
return ch, true // the C goto over: re-dispatch as the run command
}
// repeatCommand handles 'a': replay the last command by re-dispatching
// it (command.c).
func (g *RogueGame) repeatCommand() (byte, bool) {
if g.LastComm == 0 {
g.msg("you haven't typed a command yet")
g.After = false
return 0, false
}
g.Again = true
return g.LastComm, true // the C goto over: replay the last command
}
// moveOnCommand handles 'm': move without picking up; the direction key
// re-dispatches (command.c).
func (g *RogueGame) moveOnCommand() (byte, bool) {
g.MoveOn = true
if !g.promptDirection() {
g.After = false
return 0, false
}
g.countCh = g.DirCh
return g.DirCh, true // the C goto over: move onto it without pickup
}
// pickupCommand handles ',': pick up whatever is here (command.c).
func (g *RogueGame) pickupCommand() {
p := &g.Player
var found *Object
for _, obj := range g.Level.Objects {
if obj.Pos.Y == p.Pos.Y && obj.Pos.X == p.Pos.X {
found = obj
break
}
}
if found != nil {
if !g.levitCheck() {
g.pickUp(found.Kind.Glyph())
}
return
}
if !g.Options.Terse {
g.addmsgf("there is ")
}
g.addmsgf("nothing here")
if !g.Options.Terse {
g.addmsgf(" to pick up")
}
g.endmsg()
}
// fightCommand handles the f/F fight prefix; a true again asks dispatch
// to rerun with the direction key (command.c).
func (g *RogueGame) fightCommand(ch byte) (byte, bool) {
p := &g.Player
if ch == 'F' {
g.Kamikaze = true
}
if !g.promptDirection() {
g.After = false
return 0, false
}
g.Delta.Y += p.Pos.Y
g.Delta.X += p.Pos.X
mp := g.Level.MonsterAt(g.Delta.Y, g.Delta.X)
if mp == nil || (!g.seeMonst(mp) && !p.On(SenseMonsters)) {
if !g.Options.Terse {
g.addmsgf("I see ")
}
g.msg("no monster there")
g.After = false
} else if g.diagOk(p.Pos, g.Delta) {
g.ToDeath = true
g.MaxHit = 0
mp.Flags.Set(Targeted)
g.RunCh = g.DirCh
return g.DirCh, true // the C goto over: fight by running at it
}
return 0, false
}
// identifyTrapCommand handles '^': name the trap in a direction
// (command.c).
func (g *RogueGame) identifyTrapCommand() {
p := &g.Player
g.After = false
if !g.promptDirection() {
return
}
g.Delta.Y += p.Pos.Y
g.Delta.X += p.Pos.X
fp := g.Level.FlagsAt(g.Delta.Y, g.Delta.X)
if !g.Options.Terse {
g.addmsgf("You have found ")
}
switch {
case g.Level.Char(g.Delta.Y, g.Delta.X) != Trap:
g.msg("no trap there")
case p.On(Hallucinating):
g.msg("%s", g.data.trName[g.rnd(NumTrapTypes)])
default:
g.msg("%s", g.data.trName[*fp&FTrapMask])
fp.Set(FSeen)
}
}
// wizardToggleCommand handles '+': leave wizard mode (command.c
// command). C's arm lives in the main command switch under
// #ifdef MASTER, not in the wizard sub-switch, so it is reachable
// whether or not wizard is set.
//
// The entry half is deliberately not ported. C ran wizard = passwd(),
// which compared a DES-crypted answer against a compiled-in password;
// this port drops that machinery and makes wizard mode configuration
// instead (ROGUE_WIZARD, ARCHITECTURE.md §9). A password check that is
// gone is a password check that can never succeed, so the else arm
// reduces to exactly what C did when the answer was wrong: wizard stays
// off and the game says "sorry". No prompt is shown, since nothing typed
// into it could change the outcome, and the noscore/turn_see(FALSE)
// bookkeeping of C's success branch is unreachable and so is absent.
func (g *RogueGame) wizardToggleCommand() {
g.After = false
if !g.Wizard {
g.msg("sorry")
return
}
g.Wizard = false
// Re-hide the monsters wizard sight was showing: without this there
// is no way back to normal visibility.
g.turnSee(true)
g.msg("not wizard any more")
}
// wizardCommand handles the MASTER debug commands (command.c).
func (g *RogueGame) wizardCommand(ch byte) {
p := &g.Player
switch ch {
case '|':
g.msg("@ %d,%d", p.Pos.Y, p.Pos.X)
case 'C':
g.createObj()
case '$':
g.msg("inpack = %d", p.Inpack)
case CTRL('G'):
g.inventory(g.Level.Objects, 0)
case CTRL('W'):
g.whatis(false, 0)
case CTRL('D'):
g.Depth++
g.NewLevel()
case CTRL('A'):
g.Depth--
g.NewLevel()
case CTRL('F'):
g.showMap()
default:
g.wizardDebugCommand(ch)
}
}
// wizardDebugCommand handles the rest of the MASTER debug commands
// (command.c).
func (g *RogueGame) wizardDebugCommand(ch byte) {
p := &g.Player
switch ch {
case CTRL('T'):
g.teleport()
case CTRL('E'):
g.msg("food left: %d", p.FoodLeft)
case CTRL('C'):
g.addPass()
case CTRL('X'):
g.turnSee(p.On(SenseMonsters))
case CTRL('~'):
if item, ok := g.promptPackItem("charge", KindWand); ok {
item.Charges = 10000
}
case CTRL('I'):
g.wizardKit()
case '*':
g.prList()
default:
g.illcom(ch)
}
}
// wizardKit levels the wizard up and equips him (the ^I case of the
// command.c wizard switch).
func (g *RogueGame) wizardKit() {
p := &g.Player
for range 9 {
g.raiseLevel()
}
// Give him a sword (+1,+1)
obj := newObject()
g.initWeapon(obj, WeaponTwoHandedSword)
obj.HPlus = 1
obj.DPlus = 1
g.addPack(obj, true)
p.CurWeapon = obj
// And his suit of armor
obj = newObject()
obj.Kind = KindArmor
obj.Which = int(ArmorPlateMail)
obj.ArmorClass = -5
obj.Flags.Set(Known)
obj.Count = 1
p.CurArmor = obj
g.addPack(obj, true)
}
// illcom reports an illegal command (command.c illcom).
func (g *RogueGame) illcom(ch byte) {
g.Msgs.SaveMsg = false
g.Count = 0
g.msg("illegal command '%s'", unctrl(ch))
g.Msgs.SaveMsg = true
}
// search gropes about to find hidden things (command.c search).
func (g *RogueGame) search() {
p := &g.Player
ey := p.Pos.Y + 1
ex := p.Pos.X + 1
probinc := 0
if p.On(Hallucinating) {
probinc = 3
}
if p.On(Blind) {
probinc += 2
}
found := false
for y := p.Pos.Y - 1; y <= ey; y++ {
for x := p.Pos.X - 1; x <= ex; x++ {
if y == p.Pos.Y && x == p.Pos.X {
continue
}
if g.searchSpot(y, x, probinc) {
found = true
}
}
}
if found {
g.look(false)
}
}
// searchSpot tries to reveal one hidden thing next to the hero: a
// secret door, a hidden trap, or a secret passage (the loop body of
// command.c search). It reports whether something was found.
func (g *RogueGame) searchSpot(y, x, probinc int) bool {
fp := g.Level.FlagsAt(y, x)
if fp.Has(FReal) {
return false
}
foundone := false
switch g.Level.Char(y, x) {
case '|', '-':
if g.rnd(5+probinc) != 0 {
break
}
g.Level.SetChar(y, x, Door)
g.msg("a secret door")
foundone = true
case Floor:
foundone = g.searchFloor(fp, y, x, probinc)
case ' ':
if g.rnd(3+probinc) != 0 {
break
}
g.Level.SetChar(y, x, Passage)
foundone = true
}
if foundone {
fp.Set(FReal)
g.Count = 0
g.Running = false
}
return foundone
}
// searchFloor reveals a hidden trap underfoot and names it (the FLOOR
// arm of command.c search).
func (g *RogueGame) searchFloor(fp *PlaceFlags, y, x, probinc int) bool {
if g.rnd(2+probinc) != 0 {
return false
}
g.Level.SetChar(y, x, Trap)
if !g.Options.Terse {
g.addmsgf("you found ")
}
if g.Player.On(Hallucinating) {
g.msg("%s", g.data.trName[g.rnd(NumTrapTypes)])
} else {
g.msg("%s", g.data.trName[*fp&FTrapMask])
fp.Set(FSeen)
}
return true
}
// help gives single character help, or the whole mess if he wants it
// (command.c help).
func (g *RogueGame) help() {
g.msg("character you want help for (* for all): ")
helpch := g.readchar()
g.Msgs.Mpos = 0
// If it's not a *, print the right help string or an error if he
// typed a funny character.
if helpch != '*' {
g.helpOne(helpch)
return
}
g.helpAll()
}
// helpOne prints the help line for a single key, or an error for a
// funny character (command.c help).
func (g *RogueGame) helpOne(helpch byte) {
g.move(0, 0)
for _, strp := range g.data.helpStr {
if strp.Ch == helpch {
g.Msgs.LowerMsg = true
g.msg("%s%s", unctrl(strp.Ch), strp.Desc)
g.Msgs.LowerMsg = false
return
}
}
g.msg("unknown character '%s'", unctrl(helpch))
}
// helpAll prints help for everything in two columns, then waits before
// returning to command mode (command.c help).
func (g *RogueGame) helpAll() {
numprint := g.helpLines()
hw := g.scr.Hw
hw.Clear()
cnt := 0
for _, strp := range g.data.helpStr {
if !strp.Print {
continue
}
x := 0
if cnt >= numprint {
x = NumCols / 2
}
hw.Move(cnt%numprint, x)
if strp.Ch != 0 {
hw.AddStr(unctrl(strp.Ch))
}
hw.AddStr(strp.Desc)
if cnt++; cnt >= numprint*2 {
break
}
}
hw.MvAddStr(NumLines-1, 0, "--Press space to continue--")
g.scr.RefreshWin(hw)
g.waitFor(' ')
g.msg("")
g.refresh()
}
// helpLines counts how many rows the two-column help listing needs
// (command.c help).
func (g *RogueGame) helpLines() int {
numprint := 0
for _, strp := range g.data.helpStr {
if strp.Print {
numprint++
}
}
if numprint&1 == 1 { // round odd numbers up
numprint++
}
numprint /= 2
if numprint > NumLines-1 {
numprint = NumLines - 1
}
return numprint
}
// identify tells the player what a certain thing is (command.c identify).
func (g *RogueGame) identify() {
g.msg("what do you want identified? ")
ch := g.readchar()
g.Msgs.Mpos = 0
if ch == Escape {
g.msg("")
return
}
var str string
if isUpper(ch) {
str = g.Monsters[ch-'A'].Name
} else {
str = "unknown character"
for _, hp := range g.data.identList {
if hp.Ch == ch {
str = hp.Desc
break
}
}
}
g.msg("'%s': %s", unctrl(ch), str)
}
// dLevel: he wants to go down a level (command.c d_level).
func (g *RogueGame) dLevel() {
if g.levitCheck() {
return
}
if g.Level.Char(g.Player.Pos.Y, g.Player.Pos.X) != Stairs {
g.msg("I see no way down")
} else {
g.Depth++
g.SeenStairs = false
g.NewLevel()
}
}
// uLevel: he wants to go up a level (command.c u_level).
func (g *RogueGame) uLevel() {
if g.levitCheck() {
return
}
if g.Level.Char(g.Player.Pos.Y, g.Player.Pos.X) != Stairs {
g.msg("I see no way up")
return
}
if !g.HasAmulet {
g.msg("your way is magically blocked")
return
}
g.Depth--
if g.Depth == 0 {
g.totalWinner()
}
g.NewLevel()
g.msg("you feel a wrenching sensation in your gut")
}
// levitCheck checks whether she's levitating, and if she is, prints an
// appropriate message (command.c levit_check).
func (g *RogueGame) levitCheck() bool {
if !g.Player.On(Levitating) {
return false
}
g.msg("You can't. You're floating off the ground!")
return true
}
// call allows a user to call a potion, scroll, or ring something
// (command.c call).
func (g *RogueGame) call() {
obj, ok := g.promptPackItem("call", KindCallable)
// Make certain that it is something that we want to name
if !ok {
return
}
op, elsewise, guess, ok := g.callTarget(obj)
if !ok {
return
}
elsewise, guess, ok = g.callPrelude(op, elsewise, guess)
if !ok {
return
}
g.msg("%s", g.chooseTerse("call it: ", "what do you want to call it? "))
buf := elsewise
if g.getStr(&buf, g.scr.Std) == Norm {
*guess = buf
}
}
// callTarget picks what a call names: the lore record for identifiable
// kinds, the object's own label otherwise; ok is false for food (the
// switch of command.c call).
func (g *RogueGame) callTarget(obj *Object) (*ObjInfo, string, *string, bool) {
it := &g.Items
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.Kind {
case KindRing:
return &it.Rings[obj.Which], it.RingStones[obj.Which], nil, true
case KindPotion:
return &it.Potions[obj.Which], it.PotColors[obj.Which], nil, true
case KindScroll:
return &it.Scrolls[obj.Which], it.ScrNames[obj.Which], nil, true
case KindWand:
return &it.Sticks[obj.Which], it.WandMade[obj.Which], nil, true
case KindFood:
g.msg("you can't call that anything")
return nil, "", nil, false
default:
return nil, obj.Label, &obj.Label, true
}
}
// callPrelude resolves the item's current name, reporting a previous
// guess; ok is false when the item is already identified (command.c
// call).
func (g *RogueGame) callPrelude(
op *ObjInfo, elsewise string, guess *string,
) (string, *string, bool) {
fromGuess := false
if op != nil {
if op.Know {
g.msg("that has already been identified")
return "", nil, false
}
guess = &op.Guess
if *guess != "" {
elsewise = *guess
fromGuess = true
}
} else {
fromGuess = elsewise != "" && elsewise == *guess
}
if fromGuess {
if !g.Options.Terse {
g.addmsgf("Was ")
}
g.msg("called \"%s\"", elsewise)
}
return elsewise, guess, true
}
// current prints the current weapon/armor (command.c current).
func (g *RogueGame) current(cur *Object, how, where string) {
g.After = false
if cur == nil {
if !g.Options.Terse {
g.addmsgf("you are ")
}
g.addmsgf("%s nothing", how)
if where != "" {
g.addmsgf(" %s", where)
}
g.endmsg()
return
}
if !g.Options.Terse {
g.addmsgf("you are %s (", how)
}
g.InvDescribe = false
g.addmsgf("%c) %s", cur.PackCh, g.inventoryName(cur, true))
g.InvDescribe = true
if where != "" {
g.addmsgf(" %s", where)
}
g.endmsg()
}
// shell lets them escape for a while (main.c shell). The terminal decides
// how to actually suspend; headless terminals just decline.
func (g *RogueGame) shell() {
g.After = false
if se, ok := g.scr.term.(interface{ ShellEscape() }); ok {
g.InShell = true
g.runShellEscape(se)
g.InShell = false
g.refresh()
} else {
g.msg("shell escape is not available")
}
}
// runShellEscape runs the shell and returns when it exits, answering
// signal-triggered autosave requests in the meantime (issue #24).
//
// The shell blocks for as long as the player is away — minutes, or until
// they forget — and a line dropping while they are in it is exactly the
// case SIGHUP autosave exists for, so this goroutine cannot simply sit
// inside the call. The shell runs on a helper goroutine and the game
// goroutine waits here, still the only one that ever encodes game state.
// It draws nothing while it waits, so the suspend/resume dance is as
// undisturbed as it was when it ran inline (ARCHITECTURE.md section 9).
//
// A panic out of ShellEscape must not be allowed to unwind on the helper
// goroutine. term.Tcell.ShellEscape panics when Screen.Resume fails, and
// a panic reaching the top of any goroutine kills the process without
// running any *other* goroutine's deferred calls — which is where
// cmd/rogue/main.go's `defer t.Fini()` lives. Running the shell off the
// game goroutine would therefore have left the tty raw on exactly the
// path where the terminal is already broken, reintroducing issue #12 on a
// path this change created. So the helper recovers, and the value is
// re-raised below on the game goroutine, whose stack does have Fini in
// it. The recover deferral is registered after `defer close(done)` and so
// runs before it, which is what publishes panicVal to the reader.
func (g *RogueGame) runShellEscape(se interface{ ShellEscape() }) {
done := make(chan struct{})
var panicVal any
go func() {
defer close(done)
defer func() {
panicVal = recover()
}()
se.ShellEscape()
}()
for {
select {
case <-done:
if panicVal != nil {
// Re-raised here so the unwind passes through the game
// goroutine's deferred Fini. shell()'s InShell reset and
// refresh are skipped deliberately: there is no screen
// left to draw into.
panic(panicVal)
}
return
case req := <-g.sigSave:
g.runAutoSaveRequest(req)
}
}
}

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")
}
}

110
game/creature.go Normal file
View File

@@ -0,0 +1,110 @@
package game
// Creature is the _t arm of the C THING union: the player or a monster.
type Creature struct {
Pos Coord // position
Turn bool // if slowed, is it a turn to move
Type byte // what it is: 'A'..'Z' for monsters, '@' for the player
Disguise byte // what mimic looks like
OldCh byte // character that was where it was
// Dest is where it is running to — aliases live coords (hero pos,
// room gold, another monster's pos).
Dest *Coord
Flags CreatureFlags
Stats Stats
Room *Room // current room for thing
Pack []*Object // what the thing is carrying
}
// Monster is a hostile creature on the level.
type Monster struct {
Creature
}
// Player embeds Creature and owns the player-only state that was global
// in the C sources (cur_armor, purse, food_left, ...).
type Player struct {
Creature
CurArmor *Object // what he is wearing
CurWeapon *Object // which weapon he is wielding
CurRing [2]*Object // which rings are being worn (Left/Right)
PackUsed [26]bool // is the character used in the pack?
Inpack int // number of things in pack
Purse int // how much gold he has
FoodLeft int // amount of food in hero's stomach
HungryState int // how hungry is he (0..3)
NoFood int // number of levels without food
MaxStats Stats // the maximum for the player
VfHit int // number of times the flytrap has hit
}
// On is the C on(thing, flag) macro.
func (c *Creature) On(f CreatureFlags) bool { return c.Flags.Has(f) }
// IsRing is the C ISRING(hand, ring) macro.
func (p *Player) IsRing(hand int, ring RingKind) bool {
return p.CurRing[hand] != nil && p.CurRing[hand].RingKind() == ring
}
// IsWearing is the C ISWEARING(ring) macro: is the ring on either hand.
func (p *Player) IsWearing(ring RingKind) bool {
return p.IsRing(Left, ring) || p.IsRing(Right, ring)
}
// nextPackChar claims and returns the next unused pack character (pack.c
// pack_char).
func (p *Player) nextPackChar() byte {
for i := range p.PackUsed {
if !p.PackUsed[i] {
p.PackUsed[i] = true
return byte(i) + 'a'
}
}
return byte(len(p.PackUsed)) + 'a' // C would walk off the array here
}
// removeFromPack takes an item out of the pack: the whole entry, or one
// of a stack when all is false (the bookkeeping half of pack.c
// leave_pack). It returns the object that left the pack — a copy when
// newobj asks for a split.
func (p *Player) removeFromPack(obj *Object, newobj, all bool) *Object {
p.Inpack--
nobj := obj
if obj.Count > 1 && !all {
obj.Count--
if obj.Group != 0 {
p.Inpack++
}
if newobj {
copied := *obj
nobj = &copied
nobj.Count = 1
}
} else {
p.PackUsed[obj.PackCh-'a'] = false
detachObj(&p.Pack, obj)
}
return nobj
}
// attachMon pushes a monster onto the front of a list (list.c attach).
func attachMon(list *[]*Monster, item *Monster) {
*list = append([]*Monster{item}, *list...)
}
// detachMon removes a monster (by identity) from a list (list.c detach).
func detachMon(list *[]*Monster, item *Monster) {
for i, m := range *list {
if m == item {
*list = append((*list)[:i], (*list)[i+1:]...)
return
}
}
}

143
game/daemon.go Normal file
View File

@@ -0,0 +1,143 @@
package game
// daemon.c — the daemon/fuse scheduler.
//
// C identifies daemons and fuses by function pointer; the port uses DaemonID
// (which the C save format itself resorted to when serializing d_list), and
// dispatches through one switch in daemons.go.
// DaemonID names a daemon/fuse callback. The numeric values match the
// function-pointer-to-int mapping in state.c rs_write_daemons.
type DaemonID int
// Daemon and fuse callback identifiers. The first block's numeric values
// match the function-pointer-to-int mapping in state.c rs_write_daemons;
// the second block covers fuses state.c never saved (the Go save format
// handles them all uniformly).
const (
DNone DaemonID = 0
DRollwand DaemonID = 1
DDoctor DaemonID = 2
DStomach DaemonID = 3
DRunners DaemonID = 4
DSwander DaemonID = 5
DNohaste DaemonID = 6
DUnconfuse DaemonID = 7
DUnsee DaemonID = 8
DSight DaemonID = 9
DVisuals DaemonID = 10
DComeDown DaemonID = 11
DLand DaemonID = 12
DTurnSee DaemonID = 13 // potions.c casts turn_see to a fuse callback
)
// Scheduling phases and slot states (daemon.c).
const (
dEmpty = 0
Before = 1 // run before the player's command
After = 2 // run after the player's command
daemonTime = -1 // d_time sentinel: a recurring daemon, not a fuse
)
// delayedAction is rogue.h struct delayed_action.
type delayedAction struct {
Type int // dEmpty, Before or After
Func DaemonID
Arg int
Time int // daemonTime for daemons; remaining turns for fuses
}
// DaemonList is the C d_list[MAXDAEMONS] plus the file statics that ride
// along with the daemon system.
type DaemonList struct {
List [MaxDaemons]delayedAction
Between int // daemons.c rollwand static `between`
}
// dSlot finds an empty slot in the daemon/fuse list (daemon.c d_slot).
func (g *RogueGame) dSlot() *delayedAction {
for i := range g.Daemons.List {
if g.Daemons.List[i].Type == dEmpty {
return &g.Daemons.List[i]
}
}
panic("ran out of fuse slots") // C: debug message in MASTER, NULL deref otherwise
}
// findSlot finds a particular slot in the table (daemon.c find_slot).
func (g *RogueGame) findSlot(f DaemonID) *delayedAction {
for i := range g.Daemons.List {
d := &g.Daemons.List[i]
if d.Type != dEmpty && d.Func == f {
return d
}
}
return nil
}
// StartDaemon starts a recurring daemon (daemon.c start_daemon).
func (g *RogueGame) StartDaemon(f DaemonID, arg, typ int) {
dev := g.dSlot()
dev.Type = typ
dev.Func = f
dev.Arg = arg
dev.Time = daemonTime
}
// KillDaemon removes a daemon from the list (daemon.c kill_daemon).
func (g *RogueGame) KillDaemon(f DaemonID) {
if dev := g.findSlot(f); dev != nil {
dev.Type = dEmpty
}
}
// DoDaemons runs all the daemons that are active with the current flag
// (daemon.c do_daemons).
func (g *RogueGame) DoDaemons(flag int) {
for i := range g.Daemons.List {
dev := &g.Daemons.List[i]
if dev.Type == flag && dev.Time == daemonTime {
g.runDaemon(dev.Func, dev.Arg)
}
}
}
// Fuse starts a countdown fuse (daemon.c fuse).
func (g *RogueGame) Fuse(f DaemonID, arg, time, typ int) {
wire := g.dSlot()
wire.Type = typ
wire.Func = f
wire.Arg = arg
wire.Time = time
}
// Lengthen extends a fuse's countdown (daemon.c lengthen).
func (g *RogueGame) Lengthen(f DaemonID, xtime int) {
if wire := g.findSlot(f); wire != nil {
wire.Time += xtime
}
}
// Extinguish puts out a fuse (daemon.c extinguish).
func (g *RogueGame) Extinguish(f DaemonID) {
if wire := g.findSlot(f); wire != nil {
wire.Type = dEmpty
}
}
// DoFuses decrements all fuses in the given phase and fires any that burn
// down (daemon.c do_fuses).
func (g *RogueGame) DoFuses(flag int) {
for i := range g.Daemons.List {
wire := &g.Daemons.List[i]
if wire.Type == flag && wire.Time > 0 {
wire.Time--
if wire.Time == 0 {
wire.Type = dEmpty
g.runDaemon(wire.Func, wire.Arg)
}
}
}
}

282
game/daemons.go Normal file
View File

@@ -0,0 +1,282 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// daemons.c — the daemon and fuse callbacks, dispatched by DaemonID.
// stomach() arrives with the endgame phase (starvation calls death()).
// runDaemon invokes the callback named by id (the call through d_func in C).
func (g *RogueGame) runDaemon(id DaemonID, arg int) {
h := g.data.daemonHandlers[id]
if h == nil {
// Handlers are added to the table as their subsystems are
// ported; reaching one that isn't there is a porting bug.
panic("daemon not yet ported")
}
h(g, arg)
}
// doctor is the healing daemon that restores hit points after rest
// (daemons.c doctor).
func (g *RogueGame) doctor(int) {
p := &g.Player
lv := p.Stats.Lvl
ohp := p.Stats.HP
g.Quiet++
if lv < 8 {
if g.Quiet+(lv<<1) > 20 {
p.Stats.HP++
}
} else if g.Quiet >= 3 {
p.Stats.HP += g.rnd(lv-7) + 1
}
if p.IsRing(Left, RingRegeneration) {
p.Stats.HP++
}
if p.IsRing(Right, RingRegeneration) {
p.Stats.HP++
}
if ohp != p.Stats.HP {
if p.Stats.HP > p.Stats.MaxHP {
p.Stats.HP = p.Stats.MaxHP
}
g.Quiet = 0
}
}
// swander is called when it is time to start rolling for wandering monsters
// (daemons.c swander).
func (g *RogueGame) swander(int) {
g.StartDaemon(DRollwand, 0, Before)
}
// rollwand rolls to see if a wandering monster starts up (daemons.c
// rollwand).
func (g *RogueGame) rollwand(int) {
if g.Daemons.Between++; g.Daemons.Between >= 4 {
if g.roll(1, 6) == 4 {
g.wanderer()
g.KillDaemon(DRollwand)
g.Fuse(DSwander, 0, wanderTime(g), Before)
}
g.Daemons.Between = 0
}
}
// wanderTime is the WANDERTIME macro: spread(70).
func wanderTime(g *RogueGame) int { return g.spread(70) }
// unconfuse releases the poor player from his confusion (daemons.c
// unconfuse).
func (g *RogueGame) unconfuse(int) {
g.Player.Flags.Clear(Confused)
g.msg("you feel less %s now", g.chooseStr("trippy", "confused"))
}
// unsee turns off the ability to see invisible (daemons.c unsee).
func (g *RogueGame) unsee(int) {
for _, th := range g.Level.Monsters {
if th.On(Invisible) && g.seeMonst(th) {
g.mvaddch(th.Pos.Y, th.Pos.X, th.OldCh)
}
}
g.Player.Flags.Clear(CanSeeInvisible)
}
// sight gives the hero his sight back (daemons.c sight).
func (g *RogueGame) sight(int) {
p := &g.Player
if p.On(Blind) {
g.Extinguish(DSight)
p.Flags.Clear(Blind)
if !p.Room.Flags.Has(Gone) {
g.enterRoom(p.Pos)
}
g.msg("%s", g.chooseStr("far out! Everything is all cosmic again",
"the veil of darkness lifts"))
}
}
// nohaste ends the hasting (daemons.c nohaste).
func (g *RogueGame) nohaste(int) {
g.Player.Flags.Clear(Hasted)
g.msg("you feel yourself slowing down")
}
// stomach digests the hero's food (daemons.c stomach).
func (g *RogueGame) stomach(int) {
p := &g.Player
origHungry := p.HungryState
if p.FoodLeft <= 0 {
g.stomachFaint()
} else {
g.stomachDigest()
}
if p.HungryState != origHungry {
p.Flags.Clear(Awake)
g.Running = false
g.ToDeath = false
g.Count = 0
}
}
// stomachFaint starves and possibly faints an empty-stomached hero (the
// no-food arm of daemons.c stomach).
func (g *RogueGame) stomachFaint() {
p := &g.Player
if p.FoodLeft--; p.FoodLeft < -StarveTime {
g.death('s')
}
// the hero is fainting
if g.NoCommand != 0 || g.rnd(5) != 0 {
return
}
g.NoCommand += g.rnd(8) + 4
p.HungryState = 3
if !g.Options.Terse {
g.addmsgf("%s", g.chooseStr(
"the munchies overpower your motor capabilities. ",
"you feel too weak from lack of food. "))
}
g.msg("%s", g.chooseStr("You freak out", "You faint"))
}
// stomachDigest burns food and reports growing hunger (the fed arm of
// daemons.c stomach).
func (g *RogueGame) stomachDigest() {
p := &g.Player
oldfood := p.FoodLeft
amulet := 0
if g.HasAmulet {
amulet = 1
}
p.FoodLeft -= g.ringEat(Left) + g.ringEat(Right) + 1 - amulet
if p.FoodLeft < MoreTime && oldfood >= MoreTime {
p.HungryState = 2
g.msg("%s", g.chooseStr(
"the munchies are interfering with your motor capabilities",
"you are starting to feel weak"))
} else if p.FoodLeft < 2*MoreTime && oldfood >= 2*MoreTime {
p.HungryState = 1
if g.Options.Terse {
g.msg("%s", g.chooseStr("getting the munchies", "getting hungry"))
} else {
g.msg("%s", g.chooseStr("you are getting the munchies",
"you are starting to get hungry"))
}
}
}
// comeDown takes the hero down off her acid trip (daemons.c come_down).
func (g *RogueGame) comeDown(int) {
p := &g.Player
if !p.On(Hallucinating) {
return
}
g.KillDaemon(DVisuals)
p.Flags.Clear(Hallucinating)
if p.On(Blind) {
return
}
// undo the things
for _, tp := range g.Level.Objects {
if g.canSee(tp.Pos.Y, tp.Pos.X) {
g.mvaddch(tp.Pos.Y, tp.Pos.X, tp.Kind.Glyph())
}
}
// undo the monsters
seemonst := p.On(SenseMonsters)
for _, tp := range g.Level.Monsters {
g.move(tp.Pos.Y, tp.Pos.X)
if g.canSee(tp.Pos.Y, tp.Pos.X) {
if !tp.On(Invisible) || p.On(CanSeeInvisible) {
g.addch(tp.Disguise)
} else {
g.addch(g.Level.Char(tp.Pos.Y, tp.Pos.X))
}
} else if seemonst {
g.standout()
g.addch(tp.Type)
g.standend()
}
}
g.msg("Everything looks SO boring now.")
}
// visuals changes the characters for the player while hallucinating
// (daemons.c visuals).
func (g *RogueGame) visuals(int) {
if !g.After || (g.Running && g.Options.Jump) {
return
}
// change the things
for _, tp := range g.Level.Objects {
if g.canSee(tp.Pos.Y, tp.Pos.X) {
g.mvaddch(tp.Pos.Y, tp.Pos.X, g.rndThing())
}
}
// change the stairs
if !g.SeenStairs && g.canSee(g.Level.Stairs.Y, g.Level.Stairs.X) {
g.mvaddch(g.Level.Stairs.Y, g.Level.Stairs.X, g.rndThing())
}
// change the monsters
g.visualMonsters()
}
// visualMonsters redraws the monsters through the hallucination (the
// monster loop of daemons.c visuals).
func (g *RogueGame) visualMonsters() {
seemonst := g.Player.On(SenseMonsters)
for _, tp := range g.Level.Monsters {
g.move(tp.Pos.Y, tp.Pos.X)
if g.seeMonst(tp) {
if tp.Type == 'X' && tp.Disguise != 'X' {
g.addch(g.rndThing())
} else {
g.addch(g.randomMonsterLetter())
}
} else if seemonst {
g.standout()
g.addch(g.randomMonsterLetter())
g.standend()
}
}
}
// land lands the hero from a levitation potion (daemons.c land).
func (g *RogueGame) land(int) {
g.Player.Flags.Clear(Levitating)
g.msg("%s", g.chooseStr("bummer! You've hit the ground",
"you float gently to the ground"))
}

67
game/dice.go Normal file
View File

@@ -0,0 +1,67 @@
package game
import (
"fmt"
"strings"
)
// Rogue expresses damage as strings like "1x4/3x6": one attack rolling
// 1d4 and a second rolling 3d6. The C code re-parsed these with atoi on
// every swing (fight.c roll_em); the port parses them once, at table
// definition or item creation, into a DiceSpec.
// DiceRoll is one attack's dice: Count rolls of a Sides-sided die.
type DiceRoll struct {
Count int
Sides int
}
// DiceSpec is the sequence of attacks a damage string described.
type DiceSpec []DiceRoll
// ParseDice parses a C damage string with the exact semantics of the
// roll_em loop: leading digits (C atoi) before and after each 'x', attacks
// separated by '/'. Junk like the bestiary's "%%%x0" placeholder parses as
// a single 0x0 attack, as it did in C.
func ParseDice(s string) DiceSpec {
var spec DiceSpec
for s != "" {
count := cAtoi(s)
xi := strings.IndexByte(s, 'x')
if xi < 0 {
break
}
s = s[xi+1:]
spec = append(spec, DiceRoll{Count: count, Sides: cAtoi(s)})
si := strings.IndexByte(s, '/')
if si < 0 {
break
}
s = s[si+1:]
}
return spec
}
// dice is the table-definition shorthand for ParseDice.
func dice(s string) DiceSpec { return ParseDice(s) }
// String renders the spec back in the classic "NxM/NxM" form.
func (d DiceSpec) String() string {
var sb strings.Builder
for i, r := range d {
if i > 0 {
sb.WriteByte('/')
}
fmt.Fprintf(&sb, "%dx%d", r.Count, r.Sides)
}
return sb.String()
}

52
game/dice_test.go Normal file
View File

@@ -0,0 +1,52 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import "testing"
// ParseDice must keep the exact semantics of the C roll_em parse loop,
// including its junk-tolerant edges: the bestiary placeholder "%%%x0" and
// the flytrap reset "000x0" both mean a single 0x0 attack.
func TestParseDice(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
len int
}{
{"1x4", "1x4", 1},
{"1x2/1x5/1x5", "1x2/1x5/1x5", 3},
{"2x12/2x4", "2x12/2x4", 2},
{"0x0", "0x0", 1},
{"000x0", "0x0", 1},
{"%%%x0", "0x0", 1},
{"", "", 0},
{"3", "", 0}, // no 'x': C parses nothing
}
for _, c := range cases {
got := ParseDice(c.in)
if len(got) != c.len || got.String() != c.want {
t.Errorf("ParseDice(%q) = %v (len %d), want %q (len %d)",
c.in, got, len(got), c.want, c.len)
}
}
}
// The bestiary and weapon tables must parse to at least one attack each so
// every creature and weapon actually swings.
func TestTablesHaveDice(t *testing.T) {
t.Parallel()
data := newGameData()
for i, m := range data.monsterTable {
if len(m.Stats.Dmg) == 0 {
t.Errorf("monster %c (%s) has no attacks", 'A'+i, m.Name)
}
}
for w, iw := range data.initWeaps {
if len(iw.dam) == 0 || len(iw.hrl) == 0 {
t.Errorf("weapon %d has empty dice", w)
}
}
}

248
game/dispatch_test.go Normal file
View File

@@ -0,0 +1,248 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"strings"
"testing"
)
// This file is the standing form of the issue #31 audit: every case
// label in C's command switch against this port's dispatch. It exists
// because a missing dispatch entry is the one porting error that leaves
// no trace at build time. The port is function-by-function, so every C
// function has a Go counterpart and a dropped key dangles nothing and
// fails to compile nowhere; it simply answers "illegal command" the
// first time a player presses it. That is how '+' (issue #11) survived
// until PR #30 found it by accident.
//
// The tables below are transcribed from origin/c-master:command.c, with
// the C line numbers alongside. Read them there with rogue.h 52-53 in
// hand:
//
// #define when break;case
// #define otherwise break;default
//
// The labels are therefore written "when 'x':", and a grep for "case "
// finds ten of the eighty. CTRL is extern.h:113, (c & 037); ESCAPE is
// rogue.h:121, 27.
//
// There are two switches and the split between them is load-bearing. A
// key C answers from the main switch (151-427) must be answered here
// whether or not wizard mode is on. A key C answers only from the
// "if (wizard) switch (ch)" sub-switch (369-423) must not be reachable
// outside it. '+' was a divergence in ordinary play, not just in wizard
// mode, precisely because it is a main-switch key.
//
// The whole sub-switch, and '+' with it, is #ifdef MASTER. This port
// targets the MASTER build: all four #ifdef MASTER sites in command.c
// (67, 128, 317, 368) are ported unconditionally, as is sticks.c 237.
// cMainSwitchTableKeys are the main-switch labels whose arms are a plain
// call, and which this port therefore answers from commandHandlers.
func cMainSwitchTableKeys() []byte {
return []byte{
',', // 153
'!', // 180
'h', 'j', 'k', 'l', 'y', 'u', 'b', 'n', // 181-188 do_move
'H', 'J', 'K', 'L', 'Y', 'U', 'B', 'N', // 189-196 do_run
't', // 241
'q', 'Q', 'i', 'I', 'd', 'r', 'e', 'w', // 258-269
'W', 'T', 'P', 'R', 'o', 'c', // 270-275
'>', '<', '?', '/', 's', 'z', 'D', // 276-286
CTRL('P'), CTRL('R'), // 287-291
'v', // 292
'S', // 295
'.', // 298 rest
' ', // 299 "legal" illegal command
'^', // 300
'+', // 318 (#ifdef MASTER)
Escape, // 339
')', ']', '=', // 354-360
'@', // 361
}
}
// cMainSwitchMultiStepKeys are the main-switch labels whose arms need
// more than a call — C's "goto over" re-dispatch, or the F-to-f
// fallthrough — and which this port therefore answers from dispatchKey's
// own switch rather than from commandHandlers. They are main-switch keys
// all the same, and a player reaches them without wizard mode.
func cMainSwitchMultiStepKeys() []byte {
return []byte{
CTRL('H'), CTRL('J'), CTRL('K'), CTRL('L'), // 197
CTRL('Y'), CTRL('U'), CTRL('B'), CTRL('N'), // 198
'F', // 214 sets kamikaze, then falls through
'f', // 217
'a', // 246
'm', // 344
}
}
// cWizardSwitchKeys are the labels of the "if (wizard) switch (ch)"
// sub-switch, which sits inside the main switch's otherwise: arm.
func cWizardSwitchKeys() []byte {
return []byte{
'|', // 371
'C', // 372
'$', // 373
CTRL('G'), CTRL('W'), // 374-375
CTRL('D'), CTRL('A'), // 376-377
CTRL('F'), CTRL('T'), // 378-379
CTRL('E'), CTRL('C'), // 380-381
CTRL('X'), // 382
CTRL('~'), // 383
CTRL('I'), // 390
'*', // 419
}
}
// assertNotIllegalCommand fails if the top line reports the key as
// illegal. illcom is the only thing that writes that message, so it is a
// reliable "the dispatch had no arm for this key" probe: it holds
// whether the arm printed its own message, printed nothing, or cleared
// the line on the way out.
func assertNotIllegalCommand(t *testing.T, g *RogueGame, ch byte) {
t.Helper()
// End() upper-cases the first letter, so match from the second.
if line := g.scr.Std.Line(0); strings.Contains(line, "llegal command") {
t.Errorf("dispatching '%s' reached illcom (top line %q); C answers "+
"it from command.c's switch", unctrl(ch), strings.TrimSpace(line))
}
}
// TestCommandHandlersMatchCMainSwitch pins commandHandlers to exactly the
// set of main-switch keys C answers with a plain call. It is checked in
// both directions on purpose. A missing key is the '+' bug. An extra key
// is the same bug mirrored: the most likely way to acquire one is to
// promote a key out of the wizard sub-switch, which would make a MASTER
// debug command available in ordinary play.
func TestCommandHandlersMatchCMainSwitch(t *testing.T) {
t.Parallel()
handlers := newGameData().commandHandlers
keys := cMainSwitchTableKeys()
want := make(map[byte]bool, len(keys))
for _, ch := range keys {
want[ch] = true
if _, ok := handlers[ch]; !ok {
t.Errorf("commandHandlers has no entry for '%s'; C answers it "+
"from the main command.c switch, so this port says "+
"\"illegal command\" where C does not", unctrl(ch))
}
}
for ch := range handlers {
if !want[ch] {
t.Errorf("commandHandlers has an entry for '%s' that C's main "+
"switch does not; if C answers it only under if (wizard), "+
"it belongs in wizardCommand", unctrl(ch))
}
}
if len(want) != len(keys) {
t.Errorf("cMainSwitchTableKeys lists %d keys, %d of them distinct; "+
"a duplicate hides a missing key", len(keys), len(want))
}
}
// TestDispatchKeyAnswersCMultiStepKeys covers the main-switch keys that
// commandHandlers cannot hold, which the set-equality test above cannot
// see. Removing one of these from dispatchKey's switch is just as silent
// as removing a map entry: it falls into the default arm and lands on
// illcom, so that is what is checked.
func TestDispatchKeyAnswersCMultiStepKeys(t *testing.T) {
t.Parallel()
for _, ch := range cMainSwitchMultiStepKeys() {
t.Run(unctrl(ch), func(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
// Not a wizard: these are ordinary-play keys, so the default
// arm they must not reach is illcom itself.
g.Wizard = false
g.Options.Terse = false
// 'a' replays the last command; give it one to replay so it
// takes its re-dispatch arm rather than its complaint arm.
g.LastComm = '.'
// F, f and m prompt for a direction. Escape backs out of the
// prompt, which keeps them from moving the hero or starting a
// fight while still proving their arm ran.
setInput(t, g, Escape, Escape, Escape)
next, again := g.dispatchKey(ch)
t.Logf("dispatchKey(%s) = %s, again=%v",
unctrl(ch), unctrl(next), again)
assertNotIllegalCommand(t, g, ch)
})
}
}
// TestDispatchKeyRedispatchesCtrlDirections is the positive half of the
// test above for the eight ctrl-directions: C's arm converts the key to
// its upper-case run command and does "goto over" (command.c 197-213),
// which this port spells as a true second result. Checking the returned
// key, and not merely that illcom was missed, is what would catch the
// arm being present but wired to the wrong direction.
func TestDispatchKeyRedispatchesCtrlDirections(t *testing.T) {
t.Parallel()
for _, ch := range []byte{
CTRL('H'), CTRL('J'), CTRL('K'), CTRL('L'),
CTRL('Y'), CTRL('U'), CTRL('B'), CTRL('N'),
} {
t.Run(unctrl(ch), func(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
// C's "ch += ('A' - CTRL('A'))": ctrl-h becomes 'H'.
wantCh := ch + 'A' - CTRL('A')
next, again := g.dispatchKey(ch)
if !again {
t.Fatalf("dispatchKey(%s) did not ask to re-dispatch; C's "+
"arm ends in goto over", unctrl(ch))
}
if next != wantCh {
t.Errorf("dispatchKey(%s) re-dispatched as %q, want %q",
unctrl(ch), next, wantCh)
}
})
}
}
// TestWizardDispatchAnswersCWizardSwitch is the same guard for the
// MASTER sub-switch, driven through dispatchKey rather than through
// wizardCommand directly so that the routing is covered too: these keys
// must be answered because wizard mode is on, not because they leaked
// into commandHandlers.
func TestWizardDispatchAnswersCWizardSwitch(t *testing.T) {
t.Parallel()
for _, ch := range cWizardSwitchKeys() {
t.Run(unctrl(ch), func(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
g.Wizard = true
// ctrl-a is "level--; new_level()", so start deep enough for
// it to have somewhere to go.
g.Depth = 5
// Escape backs out of the item and type prompts that ctrl-w,
// ctrl-~, 'C' and '*' put up. testTerm keeps answering after
// the script runs out, so nothing here can block.
setInput(t, g, Escape, Escape, Escape, Escape)
g.dispatchKey(ch)
assertNotIllegalCommand(t, g, ch)
})
}
}

290
game/effects_test.go Normal file
View File

@@ -0,0 +1,290 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import "testing"
// mkGameInput builds a headless game; tests script it via setInput. The
// fixed seed keeps the scripted item/monster interactions stable.
func mkGameInput(t *testing.T) *RogueGame {
t.Helper()
g := New(Params{Seed: 5, Term: &testTerm{}})
g.NewLevel()
g.Oldpos = g.Player.Pos
g.Oldrp = g.roomIn(g.Player.Pos)
return g
}
// give puts an object straight into the pack and returns its pack letter.
func give(g *RogueGame, obj *Object) byte {
obj.Count = 1
g.addPack(obj, true)
return obj.PackCh
}
// setInput replaces the scripted terminal input.
func setInput(t *testing.T, g *RogueGame, input ...byte) {
t.Helper()
tt, ok := g.scr.term.(*testTerm)
if !ok {
t.Fatal("game terminal is not a testTerm")
}
tt.input = input
tt.pos = 0
}
func TestQuaffHealingPotion(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
pot := newObject()
pot.Kind = KindPotion
pot.Which = int(PotionHealing)
ch := give(g, pot)
setInput(t, g, ch)
g.Player.Stats.HP = 1
g.quaff()
if g.Player.Stats.HP <= 1 {
t.Error("healing potion did not heal")
}
if !g.Items.Potions[PotionHealing].Know {
t.Error("healing potion not identified after drinking")
}
if len(g.Player.Pack) != 5 {
t.Errorf("potion not consumed: %d items", len(g.Player.Pack))
}
}
func TestQuaffConfusionSetsFlagAndFuse(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
pot := newObject()
pot.Kind = KindPotion
pot.Which = int(PotionConfusion)
ch := give(g, pot)
setInput(t, g, ch)
g.quaff()
if !g.Player.On(Confused) {
t.Error("confusion potion did not confuse")
}
if g.findSlot(DUnconfuse) == nil {
t.Error("no unconfuse fuse pending")
}
// Let the fuse burn down
for range 30 {
g.DoFuses(After)
}
if g.Player.On(Confused) {
t.Error("confusion never wore off")
}
}
func TestReadEnchantArmor(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
scr := newObject()
scr.Kind = KindScroll
scr.Which = int(ScrollEnchantArmor)
ch := give(g, scr)
setInput(t, g, ch)
before := g.Player.CurArmor.ArmorClass
g.readScroll()
if g.Player.CurArmor.ArmorClass != before-1 {
t.Errorf("enchant armor: AC %d -> %d, want %d",
before, g.Player.CurArmor.ArmorClass, before-1)
}
}
func TestReadHoldMonsterFreezesAdjacent(t *testing.T) {
t.Parallel()
// Note: this must not use a greedy monster ('O' orc, ISGREED): the C
// wake_monster gold-guarding check has no ISHELD guard, so the
// look(TRUE) at the end of read_scroll immediately re-wakes greedy
// monsters. The port reproduces that quirk faithfully — see
// TestHoldScrollGreedyMonsterQuirk.
g := mkGameInput(t)
tp := spawnAdjacent(g, 'Z')
tp.Flags.Set(Awake)
scr := newObject()
scr.Kind = KindScroll
scr.Which = int(ScrollHoldMonster)
ch := give(g, scr)
setInput(t, g, ch)
g.readScroll()
t.Logf("after scroll: flags=%o huh=%q", tp.Flags, g.Msgs.Huh)
if tp.On(Awake) || !tp.On(Held) {
t.Error("hold monster scroll did not hold the adjacent monster")
}
}
// TestHoldScrollGreedyMonsterQuirk documents a C behavior the port keeps:
// wake_monster's ISGREED branch lacks an ISHELD guard, so a greedy monster
// (orc) held by a scroll is re-woken by the look(TRUE) that read_scroll
// performs, ending up both held and running again.
func TestHoldScrollGreedyMonsterQuirk(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
tp := spawnAdjacent(g, 'O')
tp.Flags.Set(Awake)
scr := newObject()
scr.Kind = KindScroll
scr.Which = int(ScrollHoldMonster)
ch := give(g, scr)
setInput(t, g, ch)
g.readScroll()
t.Logf("orc after scroll: flags=%o (Awake=%v Held=%v)",
tp.Flags, tp.On(Awake), tp.On(Held))
if !tp.On(Held) {
t.Error("orc lost Held entirely")
}
if !tp.On(Awake) {
t.Error("quirk changed: greedy monster stayed held; if this is a " +
"deliberate fix, update this test and ARCHITECTURE.md")
}
}
func TestZapSlowMonster(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
tp := spawnAdjacent(g, 'Z')
stick := newObject()
stick.Kind = KindWand
stick.Which = int(WandSlowMonster)
g.fixStick(stick)
ch := give(g, stick)
setInput(t, g, ch)
g.Delta = Coord{X: 1, Y: 0} // aim at the monster
charges := stick.Charges
g.doZap()
if !tp.On(Slowed) {
t.Error("slow monster wand did not slow")
}
if stick.Charges != charges-1 {
t.Error("zap did not use a charge")
}
}
// 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()
g := New(Params{Seed: 1})
g.ParseOpts("terse,nojump,name=Conan,fruit=mango,inven=slow")
if !g.Options.Terse {
t.Error("terse not set")
}
if g.Options.Jump {
t.Error("nojump not honored")
}
if g.Whoami != "Conan" {
t.Errorf("name = %q", g.Whoami)
}
if g.Fruit != "mango" {
t.Errorf("fruit = %q", g.Fruit)
}
if g.Options.InvType != InvSlow {
t.Errorf("inven = %d", g.Options.InvType)
}
}

735
game/fight.go Normal file
View File

@@ -0,0 +1,735 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import "strconv"
// fight.c — all the fighting gets done here.
// setMname returns the monster name for the given monster (fight.c
// set_mname).
func (g *RogueGame) setMname(tp *Monster) string {
if !g.seeMonst(tp) && !g.Player.On(SenseMonsters) {
if g.Options.Terse {
return "it"
}
return "something"
}
var mname string
if g.Player.On(Hallucinating) {
var idx int
ch := g.mvinch(tp.Pos.Y, tp.Pos.X)
if isUpper(ch) {
idx = int(ch - 'A')
} else {
idx = g.rnd(26)
}
mname = g.Monsters[idx].Name
} else {
mname = g.Monsters[tp.Type-'A'].Name
}
return "the " + mname
}
// fight has the player attack the monster at mp (fight.c fight).
func (g *RogueGame) fight(mp Coord, weap *Object, thrown bool) bool {
p := &g.Player
// Find the monster we want to fight
tp := g.Level.MonsterAt(mp.Y, mp.X)
if tp == nil {
return false
}
// Since we are fighting, things are not quiet so no healing takes
// place.
g.Count = 0
g.Quiet = 0
g.runTo(mp)
if g.revealXeroc(tp) && !thrown {
return false
}
mname := g.setMname(tp)
g.HasHit = g.Options.Terse && !g.ToDeath
if g.rollAttacks(&p.Creature, &tp.Creature, weap, thrown) {
g.heroHits(tp, mname, weap, thrown)
return true
}
if thrown {
g.bounce(weap, mname, g.Options.Terse)
} else {
g.miss("", mname, g.Options.Terse)
}
return false
}
// revealXeroc lets him know it was really a xeroc (if it was one); it
// reports whether one was unmasked (the X block of fight.c fight).
func (g *RogueGame) revealXeroc(tp *Monster) bool {
p := &g.Player
if tp.Type != 'X' || tp.Disguise == 'X' || p.On(Blind) {
return false
}
tp.Disguise = 'X'
if p.On(Hallucinating) {
g.mvaddch(tp.Pos.Y, tp.Pos.X, g.randomMonsterLetter())
}
g.msg("%s", g.chooseStr("heavy! That's a nasty critter!",
"wait! That's a xeroc!"))
return true
}
// heroHits lands the hero's blow on a monster: messages, the confusing
// touch, and the kill check (the hit arm of fight.c fight).
func (g *RogueGame) heroHits(tp *Monster, mname string, weap *Object, thrown bool) {
p := &g.Player
confused := false
if thrown {
g.thunk(weap, mname, g.Options.Terse)
} else {
g.hit("", mname, g.Options.Terse)
}
if p.On(CanConfuse) {
confused = true
tp.Flags.Set(Confused)
p.Flags.Clear(CanConfuse)
g.endmsg()
g.HasHit = false
g.msg("your hands stop glowing %s", g.pickColor("red"))
}
if tp.Stats.HP <= 0 {
g.killed(tp, true)
} else if confused && !p.On(Blind) {
g.msg("%s appears confused", mname)
}
}
// attack has the monster attack the player (fight.c attack). The result
// reports that the monster took itself off the level during its own
// attack (the C -1 return).
func (g *RogueGame) attack(mp *Monster) bool {
p := &g.Player
// Since this is an attack, stop running and any healing that was
// going on at the time.
g.Running = false
g.Count = 0
g.Quiet = 0
if g.ToDeath && !mp.On(Targeted) {
g.ToDeath = false
g.Kamikaze = false
}
if mp.Type == 'X' && mp.Disguise != 'X' && !p.On(Blind) {
mp.Disguise = 'X'
if p.On(Hallucinating) {
g.mvaddch(mp.Pos.Y, mp.Pos.X, g.randomMonsterLetter())
}
}
mname := g.setMname(mp)
oldhp := p.Stats.HP
removed := false
if g.rollAttacks(&mp.Creature, &p.Creature, nil, false) {
removed = g.monsterHit(mp, mname, oldhp)
} else {
g.monsterMiss(mp, mname)
}
if g.Options.FightFlush && !g.ToDeath {
g.flushType()
}
g.Count = 0
g.status()
return removed
}
// monsterHit lands a monster's blow on the hero: messages, death and
// to-death bookkeeping, then the monster's special power (the hit arm
// of fight.c attack). It reports whether the monster removed itself.
func (g *RogueGame) monsterHit(mp *Monster, mname string, oldhp int) bool {
p := &g.Player
if mp.Type != 'I' {
if g.HasHit {
g.addmsgf(". ")
}
g.hit(mname, "", false)
} else if g.HasHit {
g.endmsg()
}
g.HasHit = false
if p.Stats.HP <= 0 {
g.death(mp.Type) // Bye bye life ...
} else if !g.Kamikaze {
oldhp -= p.Stats.HP
if oldhp > g.MaxHit {
g.MaxHit = oldhp
}
if p.Stats.HP <= g.MaxHit {
g.ToDeath = false
}
}
if !mp.On(Cancelled) {
if h := g.data.hitHandlers[mp.Type-'A']; h != nil {
return h(g, mp, mname)
}
}
return false
}
// monsterMiss handles a monster's whiffed swing (the miss arm of
// fight.c attack); ice monsters miss silently.
func (g *RogueGame) monsterMiss(mp *Monster, mname string) {
if mp.Type == 'I' {
return
}
p := &g.Player
if g.HasHit {
g.addmsgf(". ")
g.HasHit = false
}
if mp.Type == 'F' {
p.Stats.HP -= p.VfHit
if p.Stats.HP <= 0 {
g.death(mp.Type) // Bye bye life ...
}
}
g.miss(mname, "", false)
}
// The monster special-power handlers, dispatched through
// gameData.hitHandlers when an uncancelled monster's hit lands. Each is
// one case of the C attack switch; a true return means the monster
// removed itself from the level.
func (g *RogueGame) hitAquator(*Monster, string) bool {
// If an aquator hits, you can lose armor class.
g.rustArmor(g.Player.CurArmor)
return false
}
func (g *RogueGame) hitIceMonster(_ *Monster, mname string) bool {
// The ice monster freezes you
g.Player.Flags.Clear(Awake)
if g.NoCommand == 0 {
g.addmsgf("you are frozen")
if !g.Options.Terse {
g.addmsgf(" by the %s", mname)
}
g.endmsg()
}
g.NoCommand += g.rnd(2) + 2
if g.NoCommand > BoreLevel {
g.death('h')
}
return false
}
func (g *RogueGame) hitRattlesnake(*Monster, string) bool {
// Rattlesnakes have poisonous bites
if g.save(VsPoison) {
return false
}
if !g.Player.IsWearing(RingSustainStrength) {
g.changeStrength(-1)
g.msg("%s", g.chooseTerse("a bite has weakened you",
"you feel a bite in your leg and now feel weaker"))
} else if !g.ToDeath {
g.msg("%s", g.chooseTerse("bite has no effect",
"a bite momentarily weakens you"))
}
return false
}
func (g *RogueGame) hitLifeDrainer(mp *Monster, _ string) bool {
// Wraiths might drain energy levels, and Vampires can steal max_hp
p := &g.Player
chance := 30
if mp.Type == 'W' {
chance = 15
}
if g.rnd(100) >= chance {
return false
}
var fewer int
if mp.Type == 'W' {
if p.Stats.Exp == 0 {
g.death('W') // All levels gone
}
if p.Stats.Lvl--; p.Stats.Lvl == 0 {
p.Stats.Exp = 0
p.Stats.Lvl = 1
} else {
p.Stats.Exp = g.data.eLevels[p.Stats.Lvl-1] + 1
}
fewer = g.roll(1, 10)
} else {
fewer = g.roll(1, 3)
}
p.Stats.HP -= fewer
p.Stats.MaxHP -= fewer
if p.Stats.HP <= 0 {
p.Stats.HP = 1
}
if p.Stats.MaxHP <= 0 {
g.death(mp.Type)
}
g.msg("you suddenly feel weaker")
return false
}
func (g *RogueGame) hitFlytrap(*Monster, string) bool {
// Venus Flytrap stops the poor guy from moving
p := &g.Player
p.Flags.Set(Held)
p.VfHit++
g.Monsters['F'-'A'].Stats.Dmg = DiceSpec{{Count: p.VfHit, Sides: 1}}
if p.Stats.HP--; p.Stats.HP <= 0 {
g.death('F')
}
return false
}
func (g *RogueGame) hitLeprechaun(mp *Monster, _ string) bool {
// Leprechaun steals some gold
p := &g.Player
lastpurse := p.Purse
p.Purse -= g.goldCalc()
if !g.save(VsMagic) {
p.Purse -= g.goldCalc() + g.goldCalc() + g.goldCalc() + g.goldCalc()
}
if p.Purse < 0 {
p.Purse = 0
}
g.removeMon(mp.Pos, mp, false)
if p.Purse != lastpurse {
g.msg("your purse feels lighter")
}
return true
}
func (g *RogueGame) hitNymph(mp *Monster, _ string) bool {
// Nymphs steal a magic item; look through the pack and pick out one
// we like.
p := &g.Player
var steal *Object
nobj := 0
for _, obj := range p.Pack {
if obj != p.CurArmor && obj != p.CurWeapon &&
obj != p.CurRing[Left] && obj != p.CurRing[Right] &&
g.isMagic(obj) {
if nobj++; g.rnd(nobj) == 0 {
steal = obj
}
}
}
if steal == nil {
return false
}
g.removeMon(mp.Pos, g.Level.MonsterAt(mp.Pos.Y, mp.Pos.X), false)
g.leavePack(steal, false, false)
g.msg("she stole %s!", g.inventoryName(steal, true))
return true
}
// swing returns true if the swing hits (fight.c swing).
func (g *RogueGame) swing(atLvl, opArm, wplus int) bool {
res := g.rnd(20)
need := (20 - atLvl) - opArm
return res+wplus >= need
}
// rollAttacks rolls several attacks (fight.c roll_em).
func (g *RogueGame) rollAttacks(thatt, thdef *Creature, weap *Object, hurl bool) bool {
att := &thatt.Stats
def := &thdef.Stats
var (
attacks DiceSpec
hplus, dplus int
)
if weap == nil {
attacks = att.Dmg
} else {
attacks, hplus, dplus = g.weaponAttack(weap, hurl)
}
// If the creature being attacked is not running (asleep or held) then
// the attacker gets a plus four bonus to hit.
if !thdef.Flags.Has(Awake) {
hplus += 4
}
defArm := g.defenderArmor(thdef)
didHit := false
for _, atk := range attacks {
if g.swing(att.Lvl, defArm, hplus+g.data.strPlus[att.Str]) {
proll := g.roll(atk.Count, atk.Sides)
damage := dplus + proll + g.data.addDam[att.Str]
if damage > 0 {
def.HP -= damage
}
didHit = true
}
}
return didHit
}
// weaponAttack picks the dice and to-hit/damage bonuses a weapon swings
// with: ring bonuses when wielded, and launcher pairing for hurled
// missiles (the weapon preamble of fight.c roll_em).
func (g *RogueGame) weaponAttack(weap *Object, hurl bool) (DiceSpec, int, int) {
p := &g.Player
hplus := weap.HPlus
dplus := weap.DPlus
if weap == p.CurWeapon {
hplus, dplus = g.wieldedRingBonus(hplus, dplus)
}
attacks := weap.Damage
if hurl {
if weap.Flags.Has(Missile) && p.CurWeapon != nil &&
WeaponKind(p.CurWeapon.Which) == weap.Launch {
attacks = weap.HurlDmg
hplus += p.CurWeapon.HPlus
dplus += p.CurWeapon.DPlus
} else if weap.Launch < 0 {
attacks = weap.HurlDmg
}
}
return attacks, hplus, dplus
}
// wieldedRingBonus folds damage and dexterity ring bonuses into the
// wielded weapon's to-hit/damage pluses (fight.c roll_em).
func (g *RogueGame) wieldedRingBonus(hplus, dplus int) (int, int) {
p := &g.Player
if p.IsRing(Left, RingIncreaseDamage) {
dplus += p.CurRing[Left].Bonus
} else if p.IsRing(Left, RingDexterity) {
hplus += p.CurRing[Left].Bonus
}
if p.IsRing(Right, RingIncreaseDamage) {
dplus += p.CurRing[Right].Bonus
} else if p.IsRing(Right, RingDexterity) {
hplus += p.CurRing[Right].Bonus
}
return hplus, dplus
}
// defenderArmor computes the defender's effective armor class: worn
// armor and protection rings when the hero defends (the def_arm
// computation of fight.c roll_em).
func (g *RogueGame) defenderArmor(thdef *Creature) int {
p := &g.Player
def := &thdef.Stats
defArm := def.ArmorClass
if def == &p.Stats {
if p.CurArmor != nil {
defArm = p.CurArmor.ArmorClass
}
if p.IsRing(Left, RingProtection) {
defArm -= p.CurRing[Left].Bonus
}
if p.IsRing(Right, RingProtection) {
defArm -= p.CurRing[Right].Bonus
}
}
return defArm
}
// cAtoi parses a leading integer like C atoi: trailing non-digits are
// ignored rather than an error.
func cAtoi(s string) int {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
n, _ := strconv.Atoi(s[:i])
return n
}
// prname gives the print name of a combatant; "" is the player (fight.c
// prname).
func prname(mname string, upper bool) string {
out := mname
if out == "" {
out = "you"
}
if upper {
out = string(toUpper(out[0])) + out[1:]
}
return out
}
// thunk announces that a missile hit a monster (fight.c thunk).
func (g *RogueGame) thunk(weap *Object, mname string, noend bool) {
if g.ToDeath {
return
}
if weap.Kind == KindWeapon {
g.addmsgf("the %s hits ", g.Items.Weapons[weap.Which].Name)
} else {
g.addmsgf("you hit ")
}
g.addmsgf("%s", mname)
if !noend {
g.endmsg()
}
}
// hit prints a message to indicate a successful hit (fight.c hit).
func (g *RogueGame) hit(er, ee string, noend bool) {
if g.ToDeath {
return
}
g.addmsgf("%s", prname(er, true))
var s string
if g.Options.Terse {
s = " hit"
} else {
i := g.rnd(4)
if er != "" {
i += 4
}
s = g.data.hNames[i]
}
g.addmsgf("%s", s)
if !g.Options.Terse {
g.addmsgf("%s", prname(ee, false))
}
if !noend {
g.endmsg()
}
}
// miss prints a message to indicate a poor swing (fight.c miss).
func (g *RogueGame) miss(er, ee string, noend bool) {
if g.ToDeath {
return
}
g.addmsgf("%s", prname(er, true))
i := 0
if !g.Options.Terse {
i = g.rnd(4)
}
if er != "" {
i += 4
}
g.addmsgf("%s", g.data.mNames[i])
if !g.Options.Terse {
g.addmsgf(" %s", prname(ee, false))
}
if !noend {
g.endmsg()
}
}
// bounce announces that a missile missed a monster (fight.c bounce).
func (g *RogueGame) bounce(weap *Object, mname string, noend bool) {
if g.ToDeath {
return
}
if weap.Kind == KindWeapon {
g.addmsgf("the %s misses ", g.Items.Weapons[weap.Which].Name)
} else {
g.addmsgf("you missed ")
}
g.addmsgf("%s", mname)
if !noend {
g.endmsg()
}
}
// removeMon removes a monster from the screen (fight.c remove_mon).
func (g *RogueGame) removeMon(mp Coord, tp *Monster, waskill bool) {
pack := append([]*Object(nil), tp.Pack...)
for _, obj := range pack {
obj.Pos = tp.Pos
detachObj(&tp.Pack, obj)
if waskill {
g.fall(obj, false)
}
}
g.Level.SetMonsterAt(mp.Y, mp.X, nil)
g.mvaddch(mp.Y, mp.X, tp.OldCh)
g.Level.RemoveMonster(tp)
if tp.On(Targeted) {
g.Kamikaze = false
g.ToDeath = false
if g.Options.FightFlush {
g.flushType()
}
}
}
// killed is called to put a monster to death (fight.c killed).
func (g *RogueGame) killed(tp *Monster, pr bool) {
p := &g.Player
p.Stats.Exp += tp.Stats.Exp
g.killedSpecial(tp)
// Get rid of the monster.
mname := g.setMname(tp)
g.removeMon(tp.Pos, tp, true)
if pr {
if g.HasHit {
g.addmsgf(". Defeated ")
g.HasHit = false
} else {
if !g.Options.Terse {
g.addmsgf("you have ")
}
g.addmsgf("defeated ")
}
g.msg("%s", mname)
}
// Do adjustments if he went up a level
g.checkLevel()
if g.Options.FightFlush {
g.flushType()
}
}
// killedSpecial handles deaths with side effects: a flytrap releases its
// grip and a leprechaun drops its gold (the switch of fight.c killed).
func (g *RogueGame) killedSpecial(tp *Monster) {
p := &g.Player
// If the monster was a venus flytrap, un-hold him
switch tp.Type {
case 'F':
p.Flags.Clear(Held)
p.VfHit = 0
g.Monsters['F'-'A'].Stats.Dmg = dice("000x0")
case 'L':
pos, ok := g.fallpos(tp.Pos)
if ok {
tp.Room.Gold = pos
}
if ok && g.Depth >= g.MaxDepth {
gold := newObject()
gold.Kind = KindGold
gold.GoldValue = g.goldCalc()
if g.save(VsMagic) {
gold.GoldValue += g.goldCalc() + g.goldCalc() + g.goldCalc() + g.goldCalc()
}
attachObj(&tp.Pack, gold)
}
}
}
// flushType flushes typeahead for the fight_flush option (mach_dep.c
// flush_type / curses flushinp).
func (g *RogueGame) flushType() {
if f, ok := g.scr.term.(interface{ FlushInput() }); ok {
f.FlushInput()
}
}

127
game/fight_test.go Normal file
View File

@@ -0,0 +1,127 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import "testing"
// mkGame builds a headless game on a generated level, initializing the
// look() state the way playit() does before the first command.
func mkGame(t *testing.T, seed int32) *RogueGame {
t.Helper()
g := New(Params{Seed: seed, Term: &testTerm{}})
g.NewLevel()
g.Oldpos = g.Player.Pos
g.Oldrp = g.roomIn(g.Player.Pos)
return g
}
// spawnAdjacent puts a monster of the given type next to the hero.
func spawnAdjacent(g *RogueGame, typ byte) *Monster {
pos := Coord{X: g.Player.Pos.X + 1, Y: g.Player.Pos.Y}
tp := &Monster{}
g.newMonster(tp, typ, pos)
return tp
}
func TestRollEmParsesMultiAttackDice(t *testing.T) {
t.Parallel()
g := mkGame(t, 42)
att := &Creature{Stats: Stats{Str: 16, Lvl: 20, Dmg: dice("1x4/1x4/1x4")}}
def := &Creature{Stats: Stats{ArmorClass: 10, HP: 1000}}
def.Flags.Set(Awake)
// With attacker level 20 vs armor 10, swing always hits
// (rnd(20)+wplus >= (20-20)-10 is always true), so three attacks of
// 1x4 + str bonus 1 each must deal between 6 and 15 damage.
if !g.rollAttacks(att, def, nil, false) {
t.Fatal("attack with guaranteed swing missed")
}
dmg := 1000 - def.Stats.HP
if dmg < 6 || dmg > 15 {
t.Errorf("three 1x4+1 attacks dealt %d damage, want 6..15", dmg)
}
}
func TestFightKillsMonster(t *testing.T) {
t.Parallel()
g := mkGame(t, 7)
tp := spawnAdjacent(g, 'B') // bat: 1 hit die
tp.Stats.HP = 1
g.Player.Stats.Lvl = 20 // always hits
before := len(g.Level.Monsters)
g.fight(tp.Pos, g.Player.CurWeapon, false)
if len(g.Level.Monsters) != before-1 {
t.Error("monster not removed after fatal fight")
}
if g.Level.MonsterAt(tp.Pos.Y, tp.Pos.X) != nil {
t.Error("map still records dead monster")
}
if g.Player.Stats.Exp == 0 {
t.Error("no experience for the kill")
}
}
func TestAttackHurtsPlayer(t *testing.T) {
t.Parallel()
g := mkGame(t, 9)
tp := spawnAdjacent(g, 'T') // troll: 1x8/1x8/2x6
tp.Stats.Lvl = 20 // always hits
tp.Flags.Clear(Cancelled)
hpBefore := g.Player.Stats.HP
g.Player.Stats.HP = 500
g.Player.Stats.MaxHP = 500
g.attack(tp)
if g.Player.Stats.HP >= 500 {
t.Errorf("player HP unchanged (%d -> %d)", hpBefore, g.Player.Stats.HP)
}
}
func TestRunnersChaseHero(t *testing.T) {
t.Parallel()
g := mkGame(t, 3)
// Place a hobgoblin a few squares away in the hero's room and set it
// running at the hero.
p := &g.Player
pos := Coord{X: p.Pos.X + 3, Y: p.Pos.Y}
if !stepOk(g.Level.Char(pos.Y, pos.X)) || g.Level.MonsterAt(pos.Y, pos.X) != nil {
t.Skip("no clear lane on this seed")
}
tp := &Monster{}
g.newMonster(tp, 'H', pos)
tp.Flags.Set(Awake)
tp.Dest = &p.Pos
d0 := distCp(tp.Pos, p.Pos)
g.runners(0)
if d1 := distCp(tp.Pos, p.Pos); d1 >= d0 {
t.Errorf("monster did not close distance: %d -> %d", d0, d1)
}
}
func TestKilledLeprechaunDropsGoldViaFall(t *testing.T) {
t.Parallel()
g := mkGame(t, 21)
tp := spawnAdjacent(g, 'L')
tp.Stats.HP = 0
objsBefore := len(g.Level.Objects)
g.killed(tp, false)
// At max depth the leprechaun's hoard falls to the floor.
if len(g.Level.Objects) <= objsBefore-1 {
t.Error("level object list shrank unexpectedly")
}
}

357
game/game.go Normal file
View File

@@ -0,0 +1,357 @@
//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 {
PotColors [NumPotionTypes]string // p_colors: colors of the potions
ScrNames [NumScrollTypes]string // s_names: names of the scrolls
RingStones [NumRingTypes]string // r_stones: stone settings of the rings
WandMade [NumWandTypes]string // ws_made: what sticks are made of
WandType [NumWandTypes]string // ws_type: "wand" or "staff"
Things [NumThings]ObjInfo
Potions [NumPotionTypes]ObjInfo
Scrolls [NumScrollTypes]ObjInfo
Rings [NumRingTypes]ObjInfo
Sticks [NumWandTypes]ObjInfo
Weapons [NumWeaponTypes + 1]ObjInfo
Armors [NumArmorTypes]ObjInfo
Group int // group number for the next stack of missiles (weapons.c `group`)
}
// Options are the user-settable game options (options.c optlist).
type Options struct {
Terse bool // terse: shorter messages
FightFlush bool // flush: flush typeahead during battle
Jump bool // jump: show position only at end of run
SeeFloor bool // seefloor: show the lamp-illuminated floor
PassGo bool // passgo: follow turnings in passageways
Tombstone bool // tombstone: print tombstone when killed
InvType int // inven: inventory style (InvOver/InvSlow/InvClear)
}
// Params carries everything needed to construct a game.
type Params struct {
Seed int32 // dungeon number; caller derives it (time+pid or SEED)
Name string // player name (overridden by ROGUEOPTS name=)
RogueOpts string // the ROGUEOPTS environment string
Home string // home directory (save file default location)
ScorePath string // scoreboard file; empty disables scoring
Wizard bool // enable debug commands (implies NoScore)
Term Terminal // the display/input device; nil runs headless
}
// RogueGame is one complete game of Rogue: every piece of state that was a
// global (or file-scope static) in the C sources, plus the terminal it is
// played on. Construct with New, then call Run.
//
// The struct grows with the port; fields appear in the phase that ports the
// code owning them.
type RogueGame struct {
// identity / RNG
Rng *Rng
Dnum int // dungeon number
Whoami string // name of player
Fruit string // favorite fruit
Home string // user's home directory
FileName string // save file name
Wizard bool // true if allows wizard commands
NoScore bool // was a wizard sometime
// the world
Player Player
Level Level
Depth int // C `level`: what level she is on
MaxDepth int // C `max_level`: deepest player has gone
HasAmulet bool // C `amulet`: he found the amulet
SeenStairs bool // have seen the stairs (for lsd)
// turn/command engine
Playing bool // true until he quits
After bool // true if we want after daemons
Again bool // repeating the last command
Count int // number of times to repeat command
NoCommand int // number of turns asleep
NoMove int // number of turns held in place
Quiet int // number of quiet turns
Running bool // true if player is running
RunCh byte // direction player is running
DoorStop bool // stop running when we pass a door
Firstmove bool // first move after setting door_stop
MoveOn bool // next move shouldn't pick up items
ToDeath bool // fighting is to the death!
Kamikaze bool // to_death really to DEATH
HasHit bool // has a "hit" message pending in msg
MaxHit int // max damage done to her in to_death
Take byte // thing she is taking
Delta Coord
DirCh byte
LastComm byte
LastDir byte
LLastComm byte
LLastDir byte
LastPick *Object
LLastPick *Object
lastDelt Coord // misc.c get_dir static last_delt
// command.c statics
countCh byte
direction byte
newCount bool
// dungeon generation working state
maze mazeState // rooms.c maze statics
pnum int // passages.c passnum statics
newpnum bool
chRet Coord // chase.c static ch_ret: where chasing takes you
// daemons/fuses
Daemons DaemonList
// screen / messages
scr *Screen
Msgs MessageLine
statusCache statusCache
invPage invPage // things.c discovery-list pagination statics
// UI state
StatMsg bool // should status() print as a msg()
InvDescribe bool // say which way items are being used
QComm bool // are we executing a 'Q' command?
InShell bool // true if executing a shell
Oldpos Coord // position before last look() call
Oldrp *Room
NObjs int // # items listed in inventory() call
// options and item identity
Options Options
Items ItemLore
// Monsters is the per-game copy of the bestiary: the C code mutates
// the venus flytrap's damage string during play, and the table is
// part of the save state.
Monsters [26]MonsterKind
// scores
LastScore int
AllScore bool
ScorePath string
rogueOpts string // the ROGUEOPTS string, re-parsed by playit as in C
restored bool // game came from a save file; Run skips setup
// sigSave carries signal-triggered autosave requests from the signal
// goroutine to the game goroutine, which is the only one allowed to
// touch the state above (issue #24). Buffered by one: the handler
// reads exactly one signal, so there is never more than one request.
// See AutoSaveOnSignal and serviceAutoSaveRequest in save.go.
sigSave chan *autoSaveRequest
// data is the game's copy of the static tables (extern.c and friends).
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).
func New(params Params) *RogueGame {
g := &RogueGame{
data: newGameData(),
Rng: &Rng{Seed: params.Seed},
Dnum: int(params.Seed),
Whoami: params.Name,
Fruit: "slime-mold",
Home: params.Home,
Wizard: params.Wizard,
NoScore: params.Wizard,
Playing: true,
Depth: 1,
ScorePath: params.ScorePath,
LastScore: -1,
sigSave: make(chan *autoSaveRequest, 1),
}
g.Options = Options{
SeeFloor: true,
Tombstone: true,
InvType: InvOver,
}
g.InvDescribe = true
g.Msgs.SaveMsg = true
g.scr = NewScreen(params.Term)
g.Msgs.attach(g.scr, g.look, g.readchar)
g.FileName = params.Home + "/rogue.save"
g.rogueOpts = params.RogueOpts
if params.Wizard {
g.Player.Flags.Set(SenseMonsters)
}
if params.RogueOpts != "" {
g.ParseOpts(params.RogueOpts)
}
g.Monsters = g.data.monsterTable
g.Items.Group = 2 // weapons.c: int group = 2
for i := range g.Level.Passages {
g.Level.Passages[i].Flags = Gone | Dark
}
g.initProbs() // set up prob tables for objects
g.initPlayer() // set up initial player stats
g.initNames() // set up names of scrolls
g.initColors() // set up colors of potions
g.initStones() // set up stone settings of rings
g.initMaterials() // set up materials of wands
return g
}
// Run plays the game to its end: the back half of main.c main() plus
// playit(). It does not return — the game ends by exiting the process
// (see myExit); one game run is one process.
func (g *RogueGame) Run() {
g.startLevel()
g.playit()
}
// startLevel draws the first level and starts the standing daemons and
// fuses for a fresh game; a restored game brings its own (the back half
// of main.c main()).
func (g *RogueGame) startLevel() {
if g.restored {
return
}
g.NewLevel() // draw current level
// Start up daemons and fuses
g.StartDaemon(DRunners, 0, After)
g.StartDaemon(DDoctor, 0, After)
g.Fuse(DSwander, 0, wanderTime(g), After)
g.StartDaemon(DStomach, 0, After)
}
// playit is the main loop of the program (main.c playit).
func (g *RogueGame) playit() {
g.prePlay()
for g.Playing {
g.command() // command execution
}
g.endit()
}
// prePlay does the option and position setup at the top of playit,
// before the command loop (main.c playit). It is split out so tests can
// drive a bounded number of turns; the loop itself never returns,
// because game-over exits the process.
func (g *RogueGame) prePlay() {
// set up defaults for modern terminals: curses' md_hasclreol() is
// always true, so the C default inventory style applies
if !g.restored {
g.Options.InvType = InvClear
}
// parse environment declaration of options (C parses ROGUEOPTS again
// here, letting it override the terminal defaults)
if g.rogueOpts != "" {
g.ParseOpts(g.rogueOpts)
}
g.Oldpos = g.Player.Pos
g.Oldrp = g.roomIn(g.Player.Pos)
}
// endit exits the game (main.c endit).
func (g *RogueGame) endit() {
g.fatal("Okay, bye bye!\n")
}
// fatal prints a message and leaves (main.c fatal).
func (g *RogueGame) fatal(s string) {
g.mvaddstr(NumLines-2, 0, s)
g.refresh()
g.myExit()
}
// quit has the player make certain, then exits (main.c quit). The final
// scoring display arrives with the endgame phase.
func (g *RogueGame) quit(int) {
// Reset the message position in case we got here via an interrupt
if !g.QComm {
g.Msgs.Mpos = 0
}
oy, ox := g.scr.Std.GetYX()
g.msg("really quit?")
if g.readchar() == 'y' {
g.clear()
g.scr.Std.MvPrintwf(NumLines-2, 0, "You quit with %d gold pieces", g.Player.Purse)
g.move(NumLines-1, 0)
g.refresh()
g.score(g.Player.Purse, 1, 0)
g.myExit()
return
}
g.move(0, 0)
g.clrtoeol()
g.status()
g.move(oy, ox)
g.refresh()
g.Msgs.Mpos = 0
g.Count = 0
g.ToDeath = false
}

80
game/greeting_test.go Normal file
View 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")
}
})
}
}

186
game/init.go Normal file
View File

@@ -0,0 +1,186 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import "strings"
// init.c — per-game randomization of item appearances and probability
// tables, and the player roll-up.
// initPlayer rolls her up (init.c init_player).
func (g *RogueGame) initPlayer() {
p := &g.Player
p.MaxStats = g.data.initStats
p.Stats = p.MaxStats
p.FoodLeft = HungerTime
// Give him some food
obj := newObject()
obj.Kind = KindFood
obj.Count = 1
g.addPack(obj, true)
// And his suit of armor
obj = newObject()
obj.Kind = KindArmor
obj.Which = int(ArmorRingMail)
obj.ArmorClass = g.data.aClass[ArmorRingMail] - 1
obj.Flags.Set(Known)
obj.Count = 1
p.CurArmor = obj
g.addPack(obj, true)
// Give him his weaponry. First a mace.
obj = newObject()
g.initWeapon(obj, WeaponMace)
obj.HPlus = 1
obj.DPlus = 1
obj.Flags.Set(Known)
g.addPack(obj, true)
p.CurWeapon = obj
// Now a +1 bow
obj = newObject()
g.initWeapon(obj, WeaponBow)
obj.HPlus = 1
obj.Flags.Set(Known)
g.addPack(obj, true)
// Now some arrows
obj = newObject()
g.initWeapon(obj, WeaponArrow)
obj.Count = g.rnd(15) + 25
obj.Flags.Set(Known)
g.addPack(obj, true)
}
// initColors initializes the potion color scheme for this game
// (init.c init_colors).
func (g *RogueGame) initColors() {
used := make([]bool, len(g.data.rainbow))
for i := range NumPotionTypes {
var j int
for {
j = g.rnd(len(g.data.rainbow))
if !used[j] {
break
}
}
used[j] = true
g.Items.PotColors[i] = g.data.rainbow[j]
}
}
// initNames generates the names of the various scrolls (init.c init_names).
func (g *RogueGame) initNames() {
for i := range NumScrollTypes {
var cp strings.Builder
nwords := g.rnd(3) + 2
for ; nwords > 0; nwords-- {
nsyl := g.rnd(3) + 1
for ; nsyl > 0; nsyl-- {
sp := g.data.sylls[g.rnd(len(g.data.sylls))]
if cp.Len()+len(sp) > MaxNameLen {
break
}
cp.WriteString(sp)
}
cp.WriteByte(' ')
}
g.Items.ScrNames[i] = strings.TrimSuffix(cp.String(), " ")
}
}
// initStones initializes the ring stone setting scheme for this game
// (init.c init_stones).
func (g *RogueGame) initStones() {
used := make([]bool, len(g.data.stoneTable))
for i := range NumRingTypes {
var j int
for {
j = g.rnd(len(g.data.stoneTable))
if !used[j] {
break
}
}
used[j] = true
g.Items.RingStones[i] = g.data.stoneTable[j].Name
g.Items.Rings[i].Worth += g.data.stoneTable[j].Value
}
}
// initMaterials initializes the construction materials for wands and staffs
// (init.c init_materials).
func (g *RogueGame) initMaterials() {
used := make([]bool, len(g.data.woods))
metused := make([]bool, len(g.data.metals))
for i := range NumWandTypes {
var str string
for {
if g.rnd(2) == 0 {
j := g.rnd(len(g.data.metals))
if !metused[j] {
g.Items.WandType[i] = wandName
str = g.data.metals[j]
metused[j] = true
break
}
} else {
j := g.rnd(len(g.data.woods))
if !used[j] {
g.Items.WandType[i] = staffName
str = g.data.woods[j]
used[j] = true
break
}
}
}
g.Items.WandMade[i] = str
}
}
// sumProbs sums up the probabilities for items appearing, converting the
// per-item weights into a cumulative distribution (init.c sumprobs).
func sumProbs(info []ObjInfo) {
for i := 1; i < len(info); i++ {
info[i].Prob += info[i-1].Prob
}
}
// initProbs copies the base tables into the game and initializes the
// probabilities for the various items (init.c init_probs).
func (g *RogueGame) initProbs() {
g.Items.Things = g.data.baseThings
g.Items.Potions = g.data.basePotInfo
g.Items.Scrolls = g.data.baseScrInfo
g.Items.Rings = g.data.baseRingInfo
g.Items.Sticks = g.data.baseWsInfo
g.Items.Weapons = g.data.baseWeapInfo
g.Items.Armors = g.data.baseArmInfo
sumProbs(g.Items.Things[:])
sumProbs(g.Items.Potions[:])
sumProbs(g.Items.Scrolls[:])
sumProbs(g.Items.Rings[:])
sumProbs(g.Items.Sticks[:])
// C sums MAXWEAPONS, excluding the flame entry.
sumProbs(g.Items.Weapons[:NumWeaponTypes])
sumProbs(g.Items.Armors[:])
}
// pickColor returns the given color, or a random one if the hero is
// hallucinating (init.c pick_color).
func (g *RogueGame) pickColor(col string) string {
if g.Player.On(Hallucinating) {
return g.data.rainbow[g.rnd(len(g.data.rainbow))]
}
return col
}

332
game/io.go Normal file
View File

@@ -0,0 +1,332 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import (
"fmt"
"strings"
)
// io.c — the message line, the status line, and character input.
// maxMsg is io.c MAXMSG: how much message fits before --More--.
const maxMsg = NumCols - len("--More--") - 1
// MessageLine is the io.c message machinery: the static msgbuf/newpos
// pair plus the related globals (mpos, huh, and the message-behavior
// flags). It owns the top line of the screen; attach wires in the
// display and input it needs.
type MessageLine struct {
buf strings.Builder // msgbuf
newpos int
Mpos int // where cursor is on top line
Huh string // the last message printed
SaveMsg bool // remember last msg
LowerMsg bool // messages should start w/lower case
MsgEsc bool // check for ESC from msg's --More--
scr *Screen // the top line lives on scr.Std
look func(wakeup bool) // redraw before a --More-- (misc.c look)
readChar func() byte // input for --More-- prompts
}
// Msg displays a message at the top of the screen (io.c msg). It returns
// Escape if the player escaped out of a --More--, ^Escape otherwise (the
// C convention: callers compare against ESCAPE).
func (m *MessageLine) Msg(format string, a ...any) int {
// if the string is "", just clear the line
if format == "" {
m.scr.Std.Move(0, 0)
m.scr.Std.Clrtoeol()
m.Mpos = 0
return ^Escape
}
// otherwise add to the message and flush it out
m.doaddf(format, a...)
return m.End()
}
// Addf adds things to the current message (io.c addmsg).
func (m *MessageLine) Addf(format string, a ...any) {
m.doaddf(format, a...)
}
// End displays a new msg, giving the player a chance to see the previous
// one if it is up there with the --More-- (io.c endmsg).
func (m *MessageLine) End() int {
if m.SaveMsg {
m.Huh = m.buf.String()
}
if m.Mpos != 0 && m.promptMore() == Escape {
return Escape
}
// All messages should start with uppercase, except ones that start
// with a pack addressing character
out := m.buf.String()
if len(out) > 0 && isLower(out[0]) && !m.LowerMsg &&
(len(out) <= 1 || out[1] != ')') {
out = string(toUpper(out[0])) + out[1:]
}
m.scr.Std.MvAddStr(0, 0, out)
m.scr.Std.Clrtoeol()
m.Mpos = m.newpos
m.newpos = 0
m.buf.Reset()
m.scr.Refresh()
return ^Escape
}
// promptMore shows the --More-- prompt and waits for the reader to
// acknowledge; Escape means the player bailed out (the Mpos block of
// io.c endmsg).
func (m *MessageLine) promptMore() int {
m.look(false)
m.scr.Std.MvAddStr(0, m.Mpos, "--More--")
m.scr.Refresh()
if !m.MsgEsc {
m.waitForSpace()
return ^Escape
}
for {
ch := m.readChar()
if ch == ' ' {
return ^Escape
}
if ch == Escape {
m.buf.Reset()
m.Mpos = 0
m.newpos = 0
return Escape
}
}
}
// attach wires the message line to its display and input; NewGame and
// Restore call it once the screen and game exist.
func (m *MessageLine) attach(scr *Screen, look func(bool), readChar func() byte) {
m.scr = scr
m.look = look
m.readChar = readChar
}
// waitForSpace absorbs input until the player types a space: the
// --More-- acknowledgement (io.c wait_for).
func (m *MessageLine) waitForSpace() {
for {
if m.readChar() == ' ' {
return
}
}
}
// doaddf performs an add onto the message buffer (io.c doadd).
func (m *MessageLine) doaddf(format string, a ...any) {
s := fmt.Sprintf(format, a...)
if len(s)+m.newpos >= maxMsg {
m.End()
}
m.buf.WriteString(s)
m.newpos = m.buf.Len()
}
// msg, addmsgf, and endmsg are the game-side shorthands for the message
// line; the machinery lives on MessageLine.
func (g *RogueGame) msg(format string, a ...any) int {
return g.Msgs.Msg(format, a...)
}
func (g *RogueGame) addmsgf(format string, a ...any) {
g.Msgs.Addf(format, a...)
}
func (g *RogueGame) endmsg() {
g.Msgs.End()
}
// stepOk returns true if it is ok to step on ch (io.c step_ok).
func stepOk(ch byte) bool {
switch ch {
case ' ', '|', '-':
return false
default:
return !isAlpha(ch)
}
}
// readchar reads and returns a character, checking for gross input errors
// (io.c readchar).
//
// Waiting for a key is where the game spends nearly all of its wall
// clock, so it is also where a signal-triggered autosave usually finds
// it: a dropped connection lands while the player is thinking, not
// mid-turn. Terminal.Interrupt wakes the read for exactly that, and the
// save runs here, on the game goroutine, before reading again.
//
// What that buys is a snapshot taken by the goroutine that owns the
// state, so it is internally consistent and restorable. It is never a
// between-commands snapshot: readchar is reached from readCommand at the
// top of a turn that has already run its BEFORE daemons and turnUpkeep,
// and from prompts raised part-way through a command — --More--,
// askOverwrite, getStr, the direction and pack prompts — by which point
// the command has mutated state as well. See serviceAutoSaveRequest
// (save.go) for the full statement of what the handoff guarantees and
// what it costs the player.
func (g *RogueGame) readchar() byte {
for {
ch, ok := g.scr.term.ReadChar()
if !ok {
g.serviceAutoSaveRequest()
continue
}
if ch == 3 { // ^C
g.quit(0)
return 27
}
return ch
}
}
// statusCache is the set of static shadow variables in io.c status() that
// suppress redundant status-line redraws.
type statusCache struct {
hpwidth int
hungry int
lvl int
pur int
hp int
arm int
str int
exp int
init bool
}
// status displays the important stats line, keeping the cursor where it was
// (io.c status).
func (g *RogueGame) status() {
s := &g.statusCache
p := &g.Player
// If nothing has changed since the last status, don't bother.
temp := p.Stats.ArmorClass
if p.CurArmor != nil {
temp = p.CurArmor.ArmorClass
}
if g.statusUnchanged(temp) {
return
}
s.init = true
s.arm = temp
oy, ox := g.scr.Std.GetYX()
if s.hp != p.Stats.MaxHP {
s.hp = p.Stats.MaxHP
s.hpwidth = 0
for t := p.Stats.MaxHP; t != 0; t /= 10 {
s.hpwidth++
}
}
// Save current status
s.lvl = g.Depth
s.pur = p.Purse
s.hp = p.Stats.HP
s.str = p.Stats.Str
s.exp = p.Stats.Exp
s.hungry = p.HungryState
line := fmt.Sprintf(
"Level: %d Gold: %-5d Hp: %*d(%*d) Str: %2d(%d) Arm: %-2d Exp: %d/%d %s",
g.Depth, p.Purse, s.hpwidth, p.Stats.HP, s.hpwidth, p.Stats.MaxHP,
p.Stats.Str, p.MaxStats.Str, 10-s.arm, p.Stats.Lvl, p.Stats.Exp,
g.data.hungerStateName[p.HungryState])
if g.StatMsg {
g.move(0, 0)
g.msg("%s", line)
} else {
g.move(StatLine, 0)
g.addstr(line)
}
g.clrtoeol()
g.move(oy, ox)
}
// statusUnchanged reports whether the status line still shows current
// values, so it need not be redrawn (the shadow-variable check of io.c
// status). temp is the effective armor class.
func (g *RogueGame) statusUnchanged(temp int) bool {
s := &g.statusCache
p := &g.Player
return s.init && s.hp == p.Stats.HP && s.exp == p.Stats.Exp &&
s.pur == p.Purse && s.arm == temp && s.str == p.Stats.Str &&
s.lvl == g.Depth && s.hungry == p.HungryState && !g.StatMsg
}
// waitFor sits around until the guy types the right key (io.c wait_for).
func (g *RogueGame) waitFor(ch byte) {
if ch == '\n' {
for {
c := g.readchar()
if c == '\n' || c == '\r' {
return
}
}
}
for {
if g.readchar() == ch {
return
}
}
}
// showWin displays a window and waits before returning (io.c show_win).
func (g *RogueGame) showWin(message string) {
g.scr.Hw.MvAddStr(0, 0, message)
g.scr.Hw.Move(g.Player.Pos.Y, g.Player.Pos.X)
g.scr.RefreshWin(g.scr.Hw)
g.waitFor(' ')
g.refresh()
}
// ASCII helpers standing in for <ctype.h>; the C game only ever handles
// 7-bit characters.
func isAlpha(c byte) bool { return isUpper(c) || isLower(c) }
func isUpper(c byte) bool { return c >= 'A' && c <= 'Z' }
func isLower(c byte) bool { return c >= 'a' && c <= 'z' }
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
func isPrint(c byte) bool { return c >= ' ' && c < 0x7f }
func toUpper(c byte) byte {
if isLower(c) {
return c - 'a' + 'A'
}
return c
}
func toLower(c byte) byte {
if isUpper(c) {
return c - 'A' + 'a'
}
return c
}

81
game/level.go Normal file
View File

@@ -0,0 +1,81 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// Place describes a spot on the level map (rogue.h PLACE).
type Place struct {
Ch byte // the base map character
Flags PlaceFlags
Monst *Monster
}
// Level is the current dungeon level: the map and everything on it. It is
// reset in place by NewLevel, matching the C reuse of the global arrays.
type Level struct {
Places [MaxLines * MaxCols]Place
Rooms [MaxRooms]Room
Passages [MaxPass]Room // one pseudo-room per passage network
Objects []*Object // lvl_obj: objects on this level
Monsters []*Monster // mlist: monsters on the level
Stairs Coord // location of the staircase
TrapCount int // number of traps on this level
}
// At returns the map cell at (y, x); the C INDEX(y,x) macro, including its
// column-major (x<<5)+y layout.
func (l *Level) At(y, x int) *Place {
return &l.Places[(x<<5)+y]
}
// Char is the chat(y,x) macro: the base map character at a spot.
func (l *Level) Char(y, x int) byte { return l.At(y, x).Ch }
// SetChar updates the base map character at a spot.
func (l *Level) SetChar(y, x int, ch byte) { l.At(y, x).Ch = ch }
// FlagsAt is the flat(y,x) macro, returned as a pointer so ported
// read-modify-write sites keep their shape.
func (l *Level) FlagsAt(y, x int) *PlaceFlags { return &l.At(y, x).Flags }
// MonsterAt is the moat(y,x) macro: the monster standing at a spot, if any.
func (l *Level) MonsterAt(y, x int) *Monster { return l.At(y, x).Monst }
// SetMonsterAt places (or clears, with nil) the monster at a spot.
func (l *Level) SetMonsterAt(y, x int, m *Monster) { l.At(y, x).Monst = m }
// VisibleChar is the winat(y,x) macro: what is apparently at a spot — a
// monster's disguise if one stands there, else the map character.
func (l *Level) VisibleChar(y, x int) byte {
if m := l.MonsterAt(y, x); m != nil {
return m.Disguise
}
return l.Char(y, x)
}
// ObjectAt finds the unclaimed object at (y, x) (misc.c find_obj).
func (l *Level) ObjectAt(y, x int) *Object {
for _, obj := range l.Objects {
if obj.Pos.Y == y && obj.Pos.X == x {
return obj
}
}
return nil
}
// AddObject puts an object on the level (list.c attach on lvl_obj).
func (l *Level) AddObject(obj *Object) { attachObj(&l.Objects, obj) }
// RemoveObject takes an object off the level (list.c detach on lvl_obj).
func (l *Level) RemoveObject(obj *Object) { detachObj(&l.Objects, obj) }
// AddMonster puts a monster on the level (list.c attach on mlist).
func (l *Level) AddMonster(m *Monster) { attachMon(&l.Monsters, m) }
// RemoveMonster takes a monster off the level (list.c detach on mlist).
func (l *Level) RemoveMonster(m *Monster) { detachMon(&l.Monsters, m) }
// goldCalc is the GOLDCALC macro: how much a gold pile is worth at depth.
func (g *RogueGame) goldCalc() int {
return g.rnd(50+10*g.Depth) + 2
}

613
game/misc.go Normal file
View File

@@ -0,0 +1,613 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// misc.c — look() display maintenance, direction input, eating, level-ups,
// and small utilities. call_it arrives with the scroll/potion phase (it
// needs the get_str line editor).
// lookScan carries the state of one look() glance while it examines the
// nine squares around the hero.
type lookScan struct {
hero Coord
pch byte // map character under the hero
pfl PlaceFlags // map flags under the hero
wakeup bool
doorStop bool // door-stop checking applies (mid-run)
sy, sx, ey, ex int
sumhero, diffhero int
passcount int
}
// look takes a quick glance all around the player (misc.c look).
func (g *RogueGame) look(wakeup bool) {
p := &g.Player
hero := p.Pos
rp := p.Room
if g.Oldpos != hero {
g.eraseLamp(g.Oldpos, g.Oldrp)
g.Oldpos = hero
g.Oldrp = rp
}
s := lookScan{
hero: hero,
wakeup: wakeup,
sy: hero.Y - 1,
sx: hero.X - 1,
ey: hero.Y + 1,
ex: hero.X + 1,
}
s.doorStop = g.DoorStop && !g.Firstmove
if s.doorStop && g.Running {
s.sumhero = hero.Y + hero.X
s.diffhero = hero.Y - hero.X
}
pp := g.Level.At(hero.Y, hero.X)
s.pch = pp.Ch
s.pfl = pp.Flags
g.lookAround(&s)
if s.doorStop && s.passcount > 1 {
g.Running = false
}
if !g.Running || !g.Options.Jump {
g.mvaddch(hero.Y, hero.X, PlayerCh)
}
}
// lookAround runs the nine-square scan of look().
func (g *RogueGame) lookAround(s *lookScan) {
for y := s.sy; y <= s.ey; y++ {
if y <= 0 || y >= NumLines-1 {
continue
}
for x := s.sx; x <= s.ex; x++ {
if x < 0 || x >= NumCols {
continue
}
g.lookCell(s, y, x)
}
}
}
// lookCell examines one square around the hero: visibility rules, trip
// and monster rendering, drawing, and run-stop checks (the loop body of
// misc.c look).
func (g *RogueGame) lookCell(s *lookScan, y, x int) {
pp := g.Level.At(y, x)
if g.lookSkips(s, pp, y, x) {
return
}
tp := pp.Monst
ch, skip := g.lookCellChar(s, tp, y, x, pp.Ch)
if skip {
return
}
if !g.lookShow(s, tp, ch, y, x) {
return
}
if s.doorStop && g.Running {
g.lookRunCheck(s, ch, y, x)
}
}
// lookSkips reports whether look ignores this square entirely: the
// hero's own square when sighted, blank rock, passage squares of
// another network, and diagonals the hero could not step to (the guard
// chain of the misc.c look loop).
func (g *RogueGame) lookSkips(s *lookScan, pp *Place, y, x int) bool {
if !g.Player.On(Blind) && y == s.hero.Y && x == s.hero.X {
return true
}
if pp.Ch == ' ' { // nothing need be done with a ' '
return true
}
return lookForeignPassage(s, pp.Flags, pp.Ch) ||
g.lookDiagonalBlocked(s, pp.Flags, pp.Ch, y, x)
}
// lookForeignPassage hides passage squares belonging to a different
// passage network than the hero's (misc.c look).
func lookForeignPassage(s *lookScan, fp PlaceFlags, ch byte) bool {
if s.pch != Door && ch != Door {
return (s.pfl & FPassage) != (fp & FPassage)
}
return false
}
// lookDiagonalBlocked hides diagonal door/passage squares the hero could
// not actually step to (misc.c look).
func (g *RogueGame) lookDiagonalBlocked(
s *lookScan, fp PlaceFlags, ch byte, y, x int,
) bool {
if !fp.Has(FPassage) && ch != Door {
return false
}
if !s.pfl.Has(FPassage) && s.pch != Door {
return false
}
return s.hero.X != x && s.hero.Y != y &&
!stepOk(g.Level.Char(y, s.hero.X)) && !stepOk(g.Level.Char(s.hero.Y, x))
}
// lookShow draws the square if it changed; it reports false when a
// blind hero cannot see it at all (the draw part of the look loop).
func (g *RogueGame) lookShow(s *lookScan, tp *Monster, ch byte, y, x int) bool {
p := &g.Player
if p.On(Blind) && (y != s.hero.Y || x != s.hero.X) {
return false
}
g.move(y, x)
if p.Room.Flags.Has(Dark) && !g.Options.SeeFloor && ch == Floor {
ch = ' '
}
if tp != nil || ch != g.inch() {
g.addch(ch)
}
return true
}
// lookCellChar picks what the square shows: trip rendering for empty
// squares, waking and disguises for monsters. skip means the square is
// not drawn at all (the monster switch of the look loop).
func (g *RogueGame) lookCellChar(
s *lookScan, tp *Monster, y, x int, ch byte,
) (byte, bool) {
p := &g.Player
switch {
case tp == nil:
return g.tripCh(y, x, ch), false
case p.On(SenseMonsters) && tp.On(Invisible):
if g.DoorStop && !g.Firstmove {
g.Running = false
}
return ch, true
default:
if s.wakeup {
g.wakeMonster(y, x)
}
if g.seeMonst(tp) {
if p.On(Hallucinating) {
return g.randomMonsterLetter(), false
}
return tp.Disguise, false
}
return ch, false
}
}
// lookRunCheck decides whether what this square shows should stop a run
// (the DoorStop tail of the misc.c look loop). Squares on the running
// edge are ignored.
func (g *RogueGame) lookRunCheck(s *lookScan, ch byte, y, x int) {
if s.atRunEdge(g.RunCh, y, x) {
return
}
switch ch {
case Door:
if x == s.hero.X || y == s.hero.Y {
g.Running = false
}
case Passage:
if x == s.hero.X || y == s.hero.Y {
s.passcount++
}
case Floor, '|', '-', ' ':
default:
g.Running = false
}
}
// atRunEdge reports whether (y, x) sits on the leading edge of the run
// direction, where door-stop checking does not apply (the first RunCh
// switch of the misc.c look loop).
func (s *lookScan) atRunEdge(runCh byte, y, x int) bool {
switch runCh {
case 'h':
return x == s.ex
case 'j':
return y == s.sy
case 'k':
return y == s.ey
case 'l':
return x == s.sx
case 'y':
return (y+x)-s.sumhero >= 1
case 'u':
return (y-x)-s.diffhero >= 1
case 'n':
return (y+x)-s.sumhero <= -1
case 'b':
return (y-x)-s.diffhero <= -1
}
return false
}
// tripCh returns the character for this space, taking into account whether
// or not the player is tripping (misc.c trip_ch).
func (g *RogueGame) tripCh(y, x int, ch byte) byte {
if g.Player.On(Hallucinating) && g.After {
switch ch {
case Floor, ' ', Passage, '-', '|', Door, Trap:
default:
if y != g.Level.Stairs.Y || x != g.Level.Stairs.X || !g.SeenStairs {
ch = g.rndThing()
}
}
}
return ch
}
// eraseLamp erases the area shown by a lamp in a dark room
// (misc.c erase_lamp).
func (g *RogueGame) eraseLamp(pos Coord, rp *Room) {
if !g.Options.SeeFloor || rp.Flags&(Gone|Dark) != Dark ||
g.Player.On(Blind) {
return
}
ey := pos.Y + 1
ex := pos.X + 1
sy := pos.Y - 1
for x := pos.X - 1; x <= ex; x++ {
for y := sy; y <= ey; y++ {
if y == g.Player.Pos.Y && x == g.Player.Pos.X {
continue
}
g.move(y, x)
if g.inch() == Floor {
g.addch(' ')
}
}
}
}
// showFloor reports whether we show the floor in her room at this time
// (misc.c show_floor).
func (g *RogueGame) showFloor() bool {
if g.Player.Room.Flags&(Gone|Dark) == Dark && !g.Player.On(Blind) {
return g.Options.SeeFloor
}
return true
}
// eat lets her try to eat something (misc.c eat).
func (g *RogueGame) eat() {
obj, ok := g.promptPackItem("eat", KindFood)
if !ok {
return
}
if obj.Kind != KindFood {
if !g.Options.Terse {
g.msg("ugh, you would get ill if you ate that")
} else {
g.msg("that's Inedible!")
}
return
}
p := &g.Player
if p.FoodLeft < 0 {
p.FoodLeft = 0
}
if p.FoodLeft += HungerTime - 200 + g.rnd(400); p.FoodLeft > StomachSize {
p.FoodLeft = StomachSize
}
p.HungryState = 0
if obj == p.CurWeapon {
p.CurWeapon = nil
}
switch {
case obj.Which == 1:
g.msg("my, that was a yummy %s", g.Fruit)
case g.rnd(100) > 70:
p.Stats.Exp++
g.msg("%s, this food tastes awful", g.chooseStr("bummer", "yuk"))
g.checkLevel()
default:
g.msg("%s, that tasted good", g.chooseStr("oh, wow", "yum"))
}
g.leavePack(obj, false, false)
}
// checkLevel checks to see if the guy has gone up a level (misc.c
// check_level).
func (g *RogueGame) checkLevel() {
p := &g.Player
var i int
for i = 0; g.data.eLevels[i] != 0; i++ {
if g.data.eLevels[i] > p.Stats.Exp {
break
}
}
i++
olevel := p.Stats.Lvl
p.Stats.Lvl = i
if i > olevel {
add := g.roll(i-olevel, 10)
p.Stats.MaxHP += add
p.Stats.HP += add
g.msg("welcome to level %d", i)
}
}
// changeStrength modifies the player's strength, keeping track of the
// highest it has been (misc.c chg_str).
func (g *RogueGame) changeStrength(amt int) {
if amt == 0 {
return
}
p := &g.Player
addStr(&p.Stats.Str, amt)
comp := p.Stats.Str
if p.IsRing(Left, RingAddStrength) {
addStr(&comp, -p.CurRing[Left].Bonus)
}
if p.IsRing(Right, RingAddStrength) {
addStr(&comp, -p.CurRing[Right].Bonus)
}
if comp > p.MaxStats.Str {
p.MaxStats.Str = comp
}
}
// addStr performs the actual strength add, checking bounds (misc.c add_str).
func addStr(sp *int, amt int) {
if *sp += amt; *sp < 3 {
*sp = 3
} else if *sp > 31 {
*sp = 31
}
}
// addHaste adds a haste to the player (misc.c add_haste).
func (g *RogueGame) addHaste(potion bool) bool {
p := &g.Player
if p.On(Hasted) {
g.NoCommand += g.rnd(8)
p.Flags.Clear(Awake | Hasted)
g.Extinguish(DNohaste)
g.msg("you faint from exhaustion")
return false
}
p.Flags.Set(Hasted)
if potion {
g.Fuse(DNohaste, 0, g.rnd(4)+4, After)
}
return true
}
// aggravate aggravates all the monsters on this level (misc.c aggravate).
func (g *RogueGame) aggravate() {
// runTo() can splice the monster list while we walk it, so iterate a copy.
monsters := append([]*Monster(nil), g.Level.Monsters...)
for _, mp := range monsters {
g.runTo(mp.Pos)
}
}
// vowelstr returns "n" if the string starts with a vowel, for "a"/"an"
// (misc.c vowelstr).
func vowelstr(str string) string {
if str == "" {
return ""
}
switch str[0] {
case 'a', 'A', 'e', 'E', 'i', 'I', 'o', 'O', 'u', 'U':
return "n"
}
return ""
}
// isCurrent sees if the object is one of the currently used items
// (misc.c is_current).
func (g *RogueGame) isCurrent(obj *Object) bool {
if obj == nil {
return false
}
p := &g.Player
if obj == p.CurArmor || obj == p.CurWeapon ||
obj == p.CurRing[Left] || obj == p.CurRing[Right] {
if !g.Options.Terse {
g.addmsgf("That's already ")
}
g.msg("in use")
return true
}
return false
}
// promptDirection sets up the direction coordinate for use in various
// "prefix" commands (misc.c get_dir).
func (g *RogueGame) promptDirection() bool {
if g.Again && g.LastDir != 0 {
g.Delta = g.lastDelt
g.DirCh = g.LastDir
} else {
prompt := "direction: "
if !g.Options.Terse {
prompt = "which direction? "
g.msg("%s", prompt)
}
for {
g.DirCh = g.readchar()
if g.DirCh == Escape {
g.LastDir = 0
g.resetLast()
return false
}
if d, ok := deltaFor(g.DirCh); ok {
g.Delta = d
break
}
g.Msgs.Mpos = 0
g.msg("%s", prompt)
}
g.DirCh = toLower(g.DirCh)
g.LastDir = g.DirCh
g.lastDelt = g.Delta
}
if g.Player.On(Confused) && g.rnd(5) == 0 {
g.confuseDirection()
}
g.Msgs.Mpos = 0
return true
}
// confuseDirection randomizes the chosen direction for a confused hero
// (the ISHUH tail of misc.c get_dir).
func (g *RogueGame) confuseDirection() {
for {
g.Delta.Y = g.rnd(3) - 1
g.Delta.X = g.rnd(3) - 1
if g.Delta.Y != 0 || g.Delta.X != 0 {
return
}
}
}
// deltaFor maps a direction key to its movement delta; ok is false for
// keys that are not directions (the switch of misc.c get_dir).
func deltaFor(ch byte) (Coord, bool) {
switch ch {
case 'h', 'H':
return Coord{X: -1, Y: 0}, true
case 'j', 'J':
return Coord{X: 0, Y: 1}, true
case 'k', 'K':
return Coord{X: 0, Y: -1}, true
case 'l', 'L':
return Coord{X: 1, Y: 0}, true
case 'y', 'Y':
return Coord{X: -1, Y: -1}, true
case 'u', 'U':
return Coord{X: 1, Y: -1}, true
case 'b', 'B':
return Coord{X: -1, Y: 1}, true
case 'n', 'N':
return Coord{X: 1, Y: 1}, true
}
return Coord{}, false
}
// callIt calls an object something after use (misc.c call_it).
func (g *RogueGame) callIt(info *ObjInfo) {
if info.Know {
info.Guess = ""
} else if info.Guess == "" {
g.msg("%s", g.chooseTerse("call it: ", "what do you want to call it? "))
buf := ""
if g.getStr(&buf, g.scr.Std) == Norm {
if buf != "" {
info.Guess = buf
}
}
}
}
// rndThing picks a random thing appropriate for this level (misc.c
// rnd_thing).
func (g *RogueGame) rndThing() byte {
var i int
if g.Depth >= AmuletLevel {
i = g.rnd(len(g.data.thingList))
} else {
i = g.rnd(len(g.data.thingList) - 1)
}
return g.data.thingList[i]
}
// chooseStr picks the first or second string depending on whether the
// player is tripping (misc.c choose_str).
func (g *RogueGame) chooseStr(ts, ns string) string {
if g.Player.On(Hallucinating) {
return ts
}
return ns
}
// unctrl gives a printable representation of a character, like curses
// unctrl(): control characters display as ^X.
func unctrl(ch byte) string {
if ch < ' ' {
return "^" + string(ch+'@')
}
if ch == 0x7f {
return "^?"
}
return string(ch)
}

240
game/monsters.go Normal file
View File

@@ -0,0 +1,240 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// monsters.c — monster creation and saving throws.
// randMonster picks a monster to show up; the lower the level, the meaner
// the monster (monsters.c randmonster).
func (g *RogueGame) randMonster(wander bool) byte {
mons := &g.data.lvlMons
if wander {
mons = &g.data.wandMons
}
for {
d := g.Depth + (g.rnd(10) - 6)
if d < 0 {
d = g.rnd(5)
}
if d > 25 {
d = g.rnd(5) + 21
}
if mons[d] != 0 {
return mons[d]
}
}
}
// newMonster picks a new monster and adds it to the list (monsters.c
// new_monster).
func (g *RogueGame) newMonster(tp *Monster, typ byte, cp Coord) {
levAdd := max(g.Depth-AmuletLevel, 0)
g.Level.AddMonster(tp)
tp.Type = typ
tp.Disguise = typ
tp.Pos = cp
g.move(cp.Y, cp.X)
tp.OldCh = g.inch()
tp.Room = g.roomIn(cp)
g.Level.SetMonsterAt(cp.Y, cp.X, tp)
mp := &g.Monsters[tp.Type-'A']
tp.Stats.Lvl = mp.Stats.Lvl + levAdd
tp.Stats.MaxHP = g.roll(tp.Stats.Lvl, 8)
tp.Stats.HP = tp.Stats.MaxHP
tp.Stats.ArmorClass = mp.Stats.ArmorClass - levAdd
tp.Stats.Dmg = mp.Stats.Dmg
tp.Stats.Str = mp.Stats.Str
tp.Stats.Exp = mp.Stats.Exp + levAdd*10 + expAdd(tp)
tp.Flags = mp.Flags
if g.Depth > 29 {
tp.Flags.Set(Hasted)
}
tp.Turn = true
tp.Pack = nil
if g.Player.IsWearing(RingAggravateMonsters) {
g.runTo(cp)
}
if typ == 'X' {
tp.Disguise = g.rndThing()
}
}
// expAdd is the experience to add for this monster's level/hit points
// (monsters.c exp_add).
func expAdd(tp *Monster) int {
var mod int
if tp.Stats.Lvl == 1 {
mod = tp.Stats.MaxHP / 8
} else {
mod = tp.Stats.MaxHP / 6
}
if tp.Stats.Lvl > 9 {
mod *= 20
} else if tp.Stats.Lvl > 6 {
mod *= 4
}
return mod
}
// wanderer creates a new wandering monster and aims it at the player
// (monsters.c wanderer).
func (g *RogueGame) wanderer() {
tp := &Monster{}
var cp Coord
for {
cp, _ = g.findFloor(true)
if g.roomIn(cp) != g.Player.Room {
break
}
}
g.newMonster(tp, g.randMonster(true), cp)
if g.Player.On(SenseMonsters) {
g.standout()
if !g.Player.On(Hallucinating) {
g.addch(tp.Type)
} else {
g.addch(g.randomMonsterLetter())
}
g.standend()
}
g.runTo(tp.Pos)
}
// wakeMonster is what to do when the hero steps next to a monster
// (monsters.c wake_monster).
func (g *RogueGame) wakeMonster(y, x int) {
p := &g.Player
tp := g.Level.MonsterAt(y, x)
if tp == nil {
panic("can't find monster in wake_monster")
}
// Every time he sees a mean monster, it might start chasing him
if g.meanWakes(tp) {
tp.Dest = &p.Pos
tp.Flags.Set(Awake)
}
if g.medusaCatches(tp) {
g.medusaGaze(tp, y, x)
}
// Let greedy ones guard gold
if tp.On(Greedy) && !tp.On(Awake) {
tp.Flags.Set(Awake)
if p.Room.GoldVal != 0 {
tp.Dest = &p.Room.Gold
} else {
tp.Dest = &p.Pos
}
}
}
// meanWakes decides whether a sleeping mean monster starts the chase
// (monsters.c wake_monster). The waking roll happens for any sleeping
// monster, as in C.
func (g *RogueGame) meanWakes(tp *Monster) bool {
p := &g.Player
return !tp.On(Awake) && g.rnd(3) != 0 && tp.On(Mean) && !tp.On(Held) &&
!p.IsWearing(RingStealth) && !p.On(Levitating)
}
// medusaCatches reports an uncovered, awake medusa the hero can see
// (monsters.c wake_monster).
func (g *RogueGame) medusaCatches(tp *Monster) bool {
p := &g.Player
return tp.Type == 'M' && !p.On(Blind) && !p.On(Hallucinating) &&
!tp.On(Found) && !tp.On(Cancelled) && tp.On(Awake)
}
// medusaGaze confuses the hero when the medusa's gaze lands (the M
// block of monsters.c wake_monster).
func (g *RogueGame) medusaGaze(tp *Monster, y, x int) {
p := &g.Player
rp := p.Room
if (rp == nil || rp.Flags.Has(Dark)) &&
distance(y, x, p.Pos.Y, p.Pos.X) >= LampDist {
return
}
tp.Flags.Set(Found)
if g.save(VsMagic) {
return
}
if p.On(Confused) {
g.Lengthen(DUnconfuse, g.spread(HuhDuration))
} else {
g.Fuse(DUnconfuse, 0, g.spread(HuhDuration), After)
}
p.Flags.Set(Confused)
mname := g.setMname(tp)
g.addmsgf("%s", mname)
if mname != "it" {
g.addmsgf("'")
}
g.msg("s gaze has confused you")
}
// givePack gives a pack to a monster if it deserves one (monsters.c
// give_pack).
func (g *RogueGame) givePack(tp *Monster) {
if g.Depth >= g.MaxDepth && g.rnd(100) < g.Monsters[tp.Type-'A'].Carry {
attachObj(&tp.Pack, g.newThing())
}
}
// saveThrow sees if a creature saves against something (monsters.c
// save_throw).
func (g *RogueGame) saveThrow(which int, st *Stats) bool {
need := 14 + which - st.Lvl/2
return g.roll(1, 20) >= need
}
// save sees if the hero saves against various nasty things (monsters.c
// save).
func (g *RogueGame) save(which int) bool {
p := &g.Player
if which == VsMagic {
if p.IsRing(Left, RingProtection) {
which -= p.CurRing[Left].Bonus
}
if p.IsRing(Right, RingProtection) {
which -= p.CurRing[Right].Bonus
}
}
return g.saveThrow(which, &p.Stats)
}
// randomMonsterLetter picks a random monster display letter, used by the
// hallucination effects (the C rnd(26)+'A' idiom).
func (g *RogueGame) randomMonsterLetter() byte {
return byte(g.rnd(26) + 'A') //nolint:gosec // G115: 'A'..'Z' fits a byte
}

502
game/move.go Normal file
View File

@@ -0,0 +1,502 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// move.c — hero movement commands.
// startRun starts the hero running (move.c do_run).
func (g *RogueGame) startRun(ch byte) {
g.Running = true
g.After = false
g.RunCh = ch
}
// moveHero checks that a move is legal and handles the consequences —
// fighting, picking up, etc. (move.c do_move). The C `goto over`
// re-check after a passage turn is the retry loop.
func (g *RogueGame) moveHero(dy, dx int) {
p := &g.Player
g.Firstmove = false
if g.NoMove > 0 {
g.NoMove--
g.msg("you are still stuck in the bear trap")
return
}
// Do a confused move (maybe)
var nh Coord
if p.On(Confused) && g.rnd(5) != 0 {
nh = g.randomStep(&p.Creature)
if nh == p.Pos {
g.After = false
g.Running = false
g.ToDeath = false
return
}
} else {
nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx}
}
for {
ch, fl, stop := g.moveTarget(nh)
if stop {
return
}
turned, ndy, ndx := g.moveResolve(nh, ch, fl, dy, dx)
if !turned {
return
}
// the C goto over: re-check the turned move
dy, dx = ndy, ndx
g.turnRefresh()
nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx}
}
}
// moveResolve acts on the square the hero stepped at: a wall may turn a
// passage runner (reported with the new deltas); anything else completes
// or refuses the move (the switch of move.c do_move).
func (g *RogueGame) moveResolve(
nh Coord, ch byte, fl PlaceFlags, dy, dx int,
) (bool, int, int) {
switch ch {
case ' ', '|', '-':
if turn, ndy, ndx := g.passageTurn(dy, dx); turn {
return true, ndy, ndx
}
g.Running = false
g.After = false
default:
g.moveEnter(nh, fl, ch)
}
return false, 0, 0
}
// moveEnter completes a step onto a walkable square: doors, traps,
// passages, floor, and things (the entry arms of the move.c do_move
// switch).
func (g *RogueGame) moveEnter(nh Coord, fl PlaceFlags, ch byte) {
p := &g.Player
switch ch {
case Door:
g.Running = false
if g.Level.FlagsAt(p.Pos.Y, p.Pos.X).Has(FPassage) {
g.enterRoom(nh)
}
case Trap:
tr := g.springTrap(nh)
if tr == TrapDoor || tr == TrapTeleport {
return
}
case Passage:
// when you're in a corridor, you don't know if you're in a maze
// room or not, and there ain't no way to find out if you're
// leaving a maze room, so it is necessary to always recalculate
// proom.
p.Room = g.roomIn(p.Pos)
case Floor:
if !fl.Has(FReal) {
g.springTrap(p.Pos)
}
default:
g.moveOnto(nh, fl, ch)
return
}
g.finishMove(nh, fl)
}
// offMap reports coordinates outside the walkable map (move.c do_move).
func offMap(nh Coord) bool {
return nh.X < 0 || nh.X >= NumCols || nh.Y <= 0 || nh.Y >= NumLines-1
}
// moveTarget inspects the square the hero is stepping onto: bounds and
// diagonal legality, hidden traps underfoot, and being held. stop means
// the move is refused (the checks of move.c do_move).
func (g *RogueGame) moveTarget(nh Coord) (byte, PlaceFlags, bool) {
p := &g.Player
// Check if he tried to move off the screen or make an illegal
// diagonal move, and stop him if he did.
if offMap(nh) {
return ' ', 0, false // fall into the wall case
}
if !g.diagOk(p.Pos, nh) {
g.After = false
g.Running = false
return 0, 0, true
}
if g.Running && p.Pos == nh {
g.After = false
g.Running = false
}
fl := *g.Level.FlagsAt(nh.Y, nh.X)
ch := g.Level.VisibleChar(nh.Y, nh.X)
if !fl.Has(FReal) && ch == Floor {
if !p.On(Levitating) {
ch = Trap
g.Level.SetChar(nh.Y, nh.X, Trap)
g.Level.FlagsAt(nh.Y, nh.X).Set(FReal)
}
} else if p.On(Held) && ch != 'F' {
g.msg("you are being held")
return 0, 0, true
}
return ch, fl, false
}
// moveOnto handles stepping at a monster or onto an item (the default
// arm of the move.c do_move switch).
func (g *RogueGame) moveOnto(nh Coord, fl PlaceFlags, ch byte) {
p := &g.Player
if ch == Stairs {
g.SeenStairs = true
}
g.Running = false
if isUpper(ch) || g.Level.MonsterAt(nh.Y, nh.X) != nil {
g.fight(nh, p.CurWeapon, false)
} else {
if ch != Stairs {
g.Take = ch
}
g.finishMove(nh, fl)
}
}
// passageTurn checks whether a runner in a gone-room passage should turn
// the corner instead of stopping at a wall (the PASSGO block of move.c
// do_move). It reports whether to turn and the new deltas, updating RunCh.
func (g *RogueGame) passageTurn(dy, dx int) (bool, int, int) {
p := &g.Player
if !g.Options.PassGo || !g.Running || !p.Room.Flags.Has(Gone) ||
p.On(Blind) {
return false, dy, dx
}
switch g.RunCh {
case 'h', 'l':
if turn, ndy := g.passageTurnVertical(); turn {
return true, ndy, 0
}
case 'j', 'k':
if turn, ndx := g.passageTurnHorizontal(); turn {
return true, 0, ndx
}
}
return false, dy, dx
}
// passageTurnVertical decides whether a horizontal runner turns up or
// down at a corner (move.c do_move).
func (g *RogueGame) passageTurnVertical() (bool, int) {
p := &g.Player
b1 := p.Pos.Y != 1 && g.turnOk(p.Pos.Y-1, p.Pos.X)
b2 := p.Pos.Y != NumLines-2 && g.turnOk(p.Pos.Y+1, p.Pos.X)
if b1 == b2 {
return false, 0
}
if b1 {
g.RunCh = 'k'
return true, -1
}
g.RunCh = 'j'
return true, 1
}
// passageTurnHorizontal decides whether a vertical runner turns left or
// right at a corner (move.c do_move).
func (g *RogueGame) passageTurnHorizontal() (bool, int) {
p := &g.Player
b1 := p.Pos.X != 0 && g.turnOk(p.Pos.Y, p.Pos.X-1)
b2 := p.Pos.X != NumCols-1 && g.turnOk(p.Pos.Y, p.Pos.X+1)
if b1 == b2 {
return false, 0
}
if b1 {
g.RunCh = 'h'
return true, -1
}
g.RunCh = 'l'
return true, 1
}
// finishMove is the move_stuff label in do_move: complete the step.
func (g *RogueGame) finishMove(nh Coord, fl PlaceFlags) {
p := &g.Player
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorAt())
if fl.Has(FPassage) && g.Level.Char(g.Oldpos.Y, g.Oldpos.X) == Door {
g.leaveRoom(nh)
}
p.Pos = nh
}
// turnOk decides whether it is legal to turn onto the given space
// (move.c turn_ok).
func (g *RogueGame) turnOk(y, x int) bool {
pp := g.Level.At(y, x)
return pp.Ch == Door || pp.Flags&(FReal|FPassage) == (FReal|FPassage)
}
// turnRefresh decides whether to refresh at a passage turning (move.c
// turnref).
func (g *RogueGame) turnRefresh() {
p := &g.Player
pp := g.Level.At(p.Pos.Y, p.Pos.X)
if !pp.Flags.Has(FSeen) {
if g.Options.Jump {
g.refresh()
}
pp.Flags.Set(FSeen)
}
}
// doorOpen is called to wake up things in a room that might move when the
// hero enters (move.c door_open).
func (g *RogueGame) doorOpen(rp *Room) {
if rp.Flags.Has(Gone) {
return
}
for y := rp.Pos.Y; y < rp.Pos.Y+rp.Max.Y; y++ {
for x := rp.Pos.X; x < rp.Pos.X+rp.Max.X; x++ {
if isUpper(g.Level.VisibleChar(y, x)) {
g.wakeMonster(y, x)
}
}
}
}
// springTrap makes him pay for stepping on a trap (move.c be_trapped).
func (g *RogueGame) springTrap(tc Coord) TrapKind {
p := &g.Player
if p.On(Levitating) {
return TrapRust // anything that's not a door or teleport
}
g.Running = false
g.Count = 0
pp := g.Level.At(tc.Y, tc.X)
pp.Ch = Trap
tr := TrapKind(pp.Flags & FTrapMask)
pp.Flags.Set(FSeen)
if h := g.data.trapHandlers[tr]; h != nil {
h(g, tc)
}
g.flushType()
return tr
}
// The per-trap effect handlers, dispatched through
// gameData.trapHandlers. Each is one case of the C be_trapped switch.
func (g *RogueGame) trapFall(Coord) {
g.Depth++
g.NewLevel()
g.msg("you fell into a trap!")
}
func (g *RogueGame) trapBear(Coord) {
g.NoMove += g.spread(3) // BEARTIME
g.msg("you are caught in a bear trap")
}
func (g *RogueGame) trapMystery(Coord) {
which := g.rnd(11)
switch which {
case 0:
g.msg("you are suddenly in a parallel dimension")
case 1:
g.msg("the light in here suddenly seems %s",
g.data.rainbow[g.rnd(len(g.data.rainbow))])
case 2:
g.msg("you feel a sting in the side of your neck")
case 3:
g.msg("multi-colored lines swirl around you, then fade")
case 4:
g.msg("a %s light flashes in your eyes", g.data.rainbow[g.rnd(len(g.data.rainbow))])
case 5:
g.msg("a spike shoots past your ear!")
default:
g.trapMysteryMore(which)
}
}
// trapMysteryMore holds the back half of the mystery-trap messages.
func (g *RogueGame) trapMysteryMore(which int) {
switch which {
case 6:
g.msg("%s sparks dance across your armor", g.data.rainbow[g.rnd(len(g.data.rainbow))])
case 7:
g.msg("you suddenly feel very thirsty")
case 8:
g.msg("you feel time speed up suddenly")
case 9:
g.msg("time now seems to be going slower")
case 10:
g.msg("you pack turns %s!", g.data.rainbow[g.rnd(len(g.data.rainbow))])
}
}
func (g *RogueGame) trapSleep(Coord) {
g.NoCommand += g.spread(5) // SLEEPTIME
g.Player.Flags.Clear(Awake)
g.msg("a strange white mist envelops you and you fall asleep")
}
func (g *RogueGame) trapArrow(Coord) {
p := &g.Player
if g.swing(p.Stats.Lvl-1, p.Stats.ArmorClass, 1) {
p.Stats.HP -= g.roll(1, 6)
if p.Stats.HP <= 0 {
g.msg("an arrow killed you")
g.death('a')
} else {
g.msg("oh no! An arrow shot you")
}
} else {
arrow := newObject()
g.initWeapon(arrow, WeaponArrow)
arrow.Count = 1
arrow.Pos = p.Pos
g.fall(arrow, false)
g.msg("an arrow shoots past you")
}
}
func (g *RogueGame) trapTeleport(tc Coord) {
// since the hero's leaving, look() won't put a TRAP down for us,
// so we have to do it ourself
g.teleport()
g.mvaddch(tc.Y, tc.X, Trap)
}
func (g *RogueGame) trapDart(Coord) {
p := &g.Player
if !g.swing(p.Stats.Lvl+1, p.Stats.ArmorClass, 1) {
g.msg("a small dart whizzes by your ear and vanishes")
return
}
p.Stats.HP -= g.roll(1, 4)
if p.Stats.HP <= 0 {
g.msg("a poisoned dart killed you")
g.death('d')
}
if !p.IsWearing(RingSustainStrength) && !g.save(VsPoison) {
g.changeStrength(-1)
}
g.msg("a small dart just hit you in the shoulder")
}
func (g *RogueGame) trapRust(Coord) {
g.msg("a gush of water hits you on the head")
g.rustArmor(g.Player.CurArmor)
}
// randomStep moves in a random direction if the monster/person is
// confused (move.c rndmove).
func (g *RogueGame) randomStep(who *Creature) Coord {
ret := Coord{
Y: who.Pos.Y + g.rnd(3) - 1,
X: who.Pos.X + g.rnd(3) - 1,
}
// Now check to see if that's a legal move. If not, don't move.
// (I.e., bump into the wall or whatever)
if ret == who.Pos {
return ret
}
if !g.diagOk(who.Pos, ret) {
return who.Pos
}
ch := g.Level.VisibleChar(ret.Y, ret.X)
if !stepOk(ch) {
return who.Pos
}
if ch == Scroll {
var found *Object
for _, obj := range g.Level.Objects {
if ret.Y == obj.Pos.Y && ret.X == obj.Pos.X {
found = obj
break
}
}
if found != nil && found.ScrollKind() == ScrollScareMonster {
return who.Pos
}
}
return ret
}
// rustArmor rusts the given armor, if it is a legal kind to rust, and we
// aren't wearing a magic ring (move.c rust_armor).
func (g *RogueGame) rustArmor(arm *Object) {
if arm == nil || arm.Kind != KindArmor || arm.ArmorKind() == ArmorLeather ||
arm.ArmorClass >= 9 {
return
}
if arm.Flags.Has(Protected) || g.Player.IsWearing(RingMaintainArmor) {
if !g.ToDeath {
g.msg("the rust vanishes instantly")
}
} else {
arm.ArmorClass++
if !g.Options.Terse {
g.msg("your armor appears to be weaker now. Oh my!")
} else {
g.msg("your armor weakens")
}
}
}

164
game/newlevel.go Normal file
View File

@@ -0,0 +1,164 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// new_level.c — dig and draw a new level.
const (
treasRoomChance = 20 // one chance in TREAS_ROOM for a treasure room
maxTreas = 10 // maximum number of treasures in a treasure room
minTreas = 2 // minimum number of treasures in a treasure room
maxTries = 10 // max number of tries to put down a monster
)
// NewLevel digs and draws a new level (new_level.c new_level).
func (g *RogueGame) NewLevel() {
p := &g.Player
p.Flags.Clear(Held) // unhold when you go down just in case
if g.Depth > g.MaxDepth {
g.MaxDepth = g.Depth
}
// Clean things off from last level
for i := range g.Level.Places {
g.Level.Places[i] = Place{Ch: ' ', Flags: FReal}
}
g.clear()
// Free up the monsters on the last level; the objects and their packs
// go with them (the garbage collector is our free_list).
g.Level.Monsters = nil
g.Level.Objects = nil
g.digRooms() // Draw rooms
g.digPassages() // Draw passages
p.NoFood++
g.putThings() // Place objects (if any)
// Place the traps
if g.rnd(10) < g.Depth {
g.Level.TrapCount = min(g.rnd(g.Depth/4)+1, MaxTraps)
for i := g.Level.TrapCount; i > 0; i-- {
// not only wouldn't it be NICE to have traps in mazes (not
// that we care about being nice), since the trap number is
// stored where the passage number is, we can't actually do it.
var stairs Coord
for {
stairs, _ = g.findFloor(false)
if g.Level.Char(stairs.Y, stairs.X) == Floor {
break
}
}
sp := g.Level.FlagsAt(stairs.Y, stairs.X)
sp.Clear(FReal)
*sp |= PlaceFlags(g.rnd(NumTrapTypes)) //nolint:gosec // G115: 0..7 fits
}
}
// Place the staircase down.
stairs, _ := g.findFloor(false)
g.Level.Stairs = stairs
g.Level.SetChar(stairs.Y, stairs.X, Stairs)
g.SeenStairs = false
for _, tp := range g.Level.Monsters {
tp.Room = g.roomIn(tp.Pos)
}
hero, _ := g.findFloor(true)
p.Pos = hero
g.enterRoom(hero)
g.mvaddch(hero.Y, hero.X, PlayerCh)
if p.On(SenseMonsters) {
g.turnSee(false)
}
if p.On(Hallucinating) {
g.visuals(0)
}
}
// randomRoom picks a room that is really there (new_level.c rnd_room).
func (g *RogueGame) randomRoom() int {
for {
rm := g.rnd(MaxRooms)
if !g.Level.Rooms[rm].Flags.Has(Gone) {
return rm
}
}
}
// putThings puts potions and scrolls on this level (new_level.c
// put_things).
func (g *RogueGame) putThings() {
// Once you have found the amulet, the only way to get new stuff is to
// go down into the dungeon.
if g.HasAmulet && g.Depth < g.MaxDepth {
return
}
// check for treasure rooms, and if so, put it in.
if g.rnd(treasRoomChance) == 0 {
g.treasureRoom()
}
// Do MAXOBJ attempts to put things on a level
for range MaxObj {
if g.rnd(100) < 36 {
// Pick a new object and link it in the list
obj := g.newThing()
g.Level.AddObject(obj)
// Put it somewhere
obj.Pos, _ = g.findFloor(false)
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, obj.Kind.Glyph())
}
}
// If he is really deep in the dungeon and he hasn't found the amulet
// yet, put it somewhere on the ground
if g.Depth >= AmuletLevel && !g.HasAmulet {
obj := newObject()
g.Level.AddObject(obj)
obj.Damage = dice("0x0")
obj.HurlDmg = dice("0x0")
obj.ArmorClass = 11
obj.Kind = KindAmulet
// Put it somewhere
obj.Pos, _ = g.findFloor(false)
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Amulet)
}
}
// treasureRoom adds a treasure room (new_level.c treas_room).
func (g *RogueGame) treasureRoom() {
rp := &g.Level.Rooms[g.randomRoom()]
spots := min((rp.Max.Y-2)*(rp.Max.X-2)-minTreas, maxTreas-minTreas)
numMonst := g.rnd(spots) + minTreas
for nm := numMonst; nm > 0; nm-- {
mp, _ := g.findFloorIn(rp, 2*maxTries, false)
tp := g.newThing()
tp.Pos = mp
g.Level.AddObject(tp)
g.Level.SetChar(mp.Y, mp.X, tp.Kind.Glyph())
}
// fill up room with monsters from the next level down
nm := max(g.rnd(spots)+minTreas, numMonst+2)
spots = (rp.Max.Y - 2) * (rp.Max.X - 2)
if nm > spots {
nm = spots
}
g.Depth++
for ; nm > 0; nm-- {
if mp, ok := g.findFloorIn(rp, maxTries, true); ok {
tp := &Monster{}
g.newMonster(tp, g.randMonster(false), mp)
tp.Flags.Set(Mean) // no sloughers in THIS room
g.givePack(tp)
}
}
g.Depth--
}

180
game/newlevel_test.go Normal file
View File

@@ -0,0 +1,180 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"strings"
"testing"
)
func genLevel(t *testing.T, seed int32) *RogueGame {
t.Helper()
g := New(Params{Seed: seed})
g.NewLevel()
return g
}
// renderMap draws the raw level map (not the screen) as text.
func renderMap(g *RogueGame) string {
var sb strings.Builder
for y := range NumLines {
for x := range NumCols {
ch := g.Level.Char(y, x)
if m := g.Level.MonsterAt(y, x); m != nil {
ch = m.Type
}
sb.WriteByte(ch)
}
sb.WriteByte('\n')
}
return sb.String()
}
func TestNewLevelInvariants(t *testing.T) {
t.Parallel()
for _, seed := range []int32{1, 12345, 2026, 99999} {
g := genLevel(t, seed)
checkHeroPlacement(t, g, seed)
checkRoomsDrawn(t, g, seed)
checkMonstersPlaced(t, g, seed)
checkObjectsPlaced(t, g, seed)
checkStartingKit(t, g, seed)
}
}
// checkHeroPlacement verifies the staircase and hero landed on valid,
// unoccupied cells.
func checkHeroPlacement(t *testing.T, g *RogueGame, seed int32) {
t.Helper()
// The staircase is somewhere real.
st := g.Level.Stairs
if g.Level.Char(st.Y, st.X) != Stairs {
t.Errorf("seed %d: no staircase at recorded stairs position", seed)
}
// The hero stands on a walkable, monster-free cell.
hp := g.Player.Pos
if !stepOk(g.Level.Char(hp.Y, hp.X)) {
t.Errorf("seed %d: hero on unwalkable cell %q", seed,
g.Level.Char(hp.Y, hp.X))
}
if g.Level.MonsterAt(hp.Y, hp.X) != nil {
t.Errorf("seed %d: hero standing on a monster", seed)
}
if g.Player.Room == nil {
t.Errorf("seed %d: hero not in any room", seed)
}
}
// checkRoomsDrawn verifies rooms and floor/passages appear on the map.
func checkRoomsDrawn(t *testing.T, g *RogueGame, seed int32) {
t.Helper()
m := renderMap(g)
if !strings.Contains(m, "|") || !strings.Contains(m, "-") {
t.Errorf("seed %d: no room walls drawn", seed)
}
if !strings.Contains(m, ".") && !strings.Contains(m, "#") {
t.Errorf("seed %d: no floor or passages drawn", seed)
}
}
// checkMonstersPlaced verifies every monster is indexed on the map and
// placed in a room.
func checkMonstersPlaced(t *testing.T, g *RogueGame, seed int32) {
t.Helper()
for _, mon := range g.Level.Monsters {
if g.Level.MonsterAt(mon.Pos.Y, mon.Pos.X) != mon {
t.Errorf("seed %d: monster %c not indexed at its position",
seed, mon.Type)
}
if mon.Room == nil {
t.Errorf("seed %d: monster %c has no room", seed, mon.Type)
}
}
}
// checkObjectsPlaced verifies every level object sits on a cell
// displaying its type (items can share cells only with monsters standing
// on them).
func checkObjectsPlaced(t *testing.T, g *RogueGame, seed int32) {
t.Helper()
for _, obj := range g.Level.Objects {
ch := g.Level.Char(obj.Pos.Y, obj.Pos.X)
if ch != obj.Kind.Glyph() &&
g.Level.MonsterAt(obj.Pos.Y, obj.Pos.X) == nil {
t.Errorf("seed %d: object %v at (%d,%d) but map shows %q",
seed, obj.Kind, obj.Pos.Y, obj.Pos.X, ch)
}
}
}
// checkStartingKit verifies the player has her starting kit: food, armor,
// mace, bow, arrows.
func checkStartingKit(t *testing.T, g *RogueGame, seed int32) {
t.Helper()
if len(g.Player.Pack) != 5 {
t.Errorf("seed %d: starting pack has %d items, want 5",
seed, len(g.Player.Pack))
}
if g.Player.CurWeapon == nil ||
g.Player.CurWeapon.WeaponKind() != WeaponMace {
t.Errorf("seed %d: not wielding the starting mace", seed)
}
if g.Player.CurArmor == nil ||
g.Player.CurArmor.ArmorKind() != ArmorRingMail {
t.Errorf("seed %d: not wearing the starting ring mail", seed)
}
}
func TestNewLevelDeterministic(t *testing.T) {
t.Parallel()
a := renderMap(genLevel(t, 12345))
b := renderMap(genLevel(t, 12345))
if a != b {
t.Error("same seed produced different levels")
}
c := renderMap(genLevel(t, 54321))
if a == c {
t.Error("different seeds produced identical levels")
}
}
// TestDeeperLevels exercises generation across many depths and seeds —
// mazes, dark rooms, traps, treasure rooms — as a crash/invariant sweep.
func TestDeeperLevels(t *testing.T) {
t.Parallel()
for _, seed := range []int32{7, 42, 1000, 31337} {
g := New(Params{Seed: seed})
for depth := 1; depth <= 30; depth++ {
g.Depth = depth
g.NewLevel()
st := g.Level.Stairs
if g.Level.Char(st.Y, st.X) != Stairs {
t.Fatalf("seed %d depth %d: missing staircase", seed, depth)
}
}
}
}

261
game/object.go Normal file
View File

@@ -0,0 +1,261 @@
package game
// ObjectKind is the category of an item. In C this was the o_type byte,
// which doubled as the character drawn on the map; the port separates the
// category (ObjectKind) from its display character (Glyph).
type ObjectKind int
// Item categories.
const (
KindNone ObjectKind = iota
KindPotion
KindScroll
KindFood
KindWeapon
KindArmor
KindRing
KindWand // a "stick": wand or staff
KindAmulet
KindGold
)
// Prompt-filter pseudo-kinds for item selection (rogue.h CALLABLE and
// R_OR_S): they never appear on an Object, only as getItem filters.
const (
KindCallable ObjectKind = -1
KindRingOrStick ObjectKind = -2
)
// Category words shared by ObjectKind.String, the discovery list, and the
// ident table. (The bare identifiers Potion, Scroll, Ring, Gold are the
// glyph byte constants.)
const (
potionName = "potion"
scrollName = "scroll"
ringName = "ring"
goldName = "gold"
)
// Glyph returns the map/display character for this kind of object.
func (k ObjectKind) Glyph() byte {
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch k {
case KindPotion:
return Potion
case KindScroll:
return Scroll
case KindFood:
return Food
case KindWeapon:
return Weapon
case KindArmor:
return Armor
case KindRing:
return Ring
case KindWand:
return Stick
case KindAmulet:
return Amulet
case KindGold:
return Gold
}
return ' '
}
// String names the category the way the C type_name() did.
func (k ObjectKind) String() string {
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch k {
case KindPotion:
return potionName
case KindScroll:
return scrollName
case KindFood:
return "food"
case KindWeapon:
return "weapon"
case KindArmor:
return "suit of armor"
case KindRing:
return ringName
default:
return k.stringRest()
}
}
// objectKindForGlyph is the reverse of Glyph: what category of item does a
// map character denote. Returns KindNone for non-item characters.
func objectKindForGlyph(ch byte) ObjectKind {
switch ch {
case Potion:
return KindPotion
case Scroll:
return KindScroll
case Food:
return KindFood
case Weapon:
return KindWeapon
case Armor:
return KindArmor
case Ring:
return KindRing
case Stick:
return KindWand
case Amulet:
return KindAmulet
case Gold:
return KindGold
}
return KindNone
}
// MergesInPack reports whether picking up another of this kind merges into
// an existing pack entry's count (rogue.h ISMULT).
func (k ObjectKind) MergesInPack() bool {
return k == KindPotion || k == KindScroll || k == KindFood
}
// stringRest names the remaining kinds, including the ring-or-stick
// prompt pseudo-kind (the tail of the C type_name switch).
func (k ObjectKind) stringRest() string {
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch k {
case KindWand:
return "wand or staff"
case KindAmulet:
return "amulet"
case KindGold:
return goldName
case KindRingOrStick:
return "ring, wand or staff"
}
return "bizarre thing"
}
// Object is the _o arm of the C THING union: anything that can lie on the
// floor or ride in a pack.
type Object struct {
Kind ObjectKind // what kind of object it is (o_type)
Pos Coord // where it lives on the screen
Text string // what it says if you read it
Launch WeaponKind // what you need to launch it (noWeapon if none)
PackCh byte // what character it is in the pack
Damage DiceSpec // damage if used like sword
HurlDmg DiceSpec // damage if thrown
Count int // count for plural objects
Which int // which object of a type it is (index for the Kind's table)
HPlus int // plusses to hit
DPlus int // plusses to damage
ArmorClass int // armor protection (armor only)
Charges int // charges remaining (wands and staffs only)
GoldValue int // worth (gold piles only)
Bonus int // magic bonus (rings only: protection, add strength, ...)
Flags ObjFlags
Group int // group number for this object
Label string // label for object
}
// newObject is list.c new_item() for objects: a zeroed Object with the
// same non-zero defaults the C code relies on.
func newObject() *Object {
return &Object{Launch: noWeapon}
}
// whichLimit reports how many entries a kind's per-kind tables hold, so
// Which is a legal index exactly while 0 <= Which < whichLimit(Kind).
// Kinds whose Which is not a table index at all — food (0 ration, 1 the
// fruit), the amulet, gold, and the KindNone an unrecognized glyph maps
// to — report 0, meaning any value is acceptable, which is what C did
// with them too.
//
// Weapons count one past NumWeaponTypes on purpose: Items.Weapons is
// sized NumWeaponTypes+1 for the WeaponFlame dragon-breath entry, and
// fireBolt really does set Which = WeaponFlame on a live Object. That
// entry has no init_dam[] row, so initWeapon and createObj bound
// themselves by NumWeaponTypes instead.
func whichLimit(kind ObjectKind) int {
//nolint:exhaustive // the remaining kinds do not index a per-kind table
switch kind {
case KindPotion:
return int(NumPotionTypes)
case KindScroll:
return int(NumScrollTypes)
case KindRing:
return int(NumRingTypes)
case KindWand:
return int(NumWandTypes)
case KindArmor:
return int(NumArmorTypes)
case KindWeapon:
return int(NumWeaponTypes) + 1
}
return 0
}
// PotionKind returns Which as a potion kind; valid only for KindPotion.
func (o *Object) PotionKind() PotionKind { return PotionKind(o.Which) }
// ScrollKind returns Which as a scroll kind; valid only for KindScroll.
func (o *Object) ScrollKind() ScrollKind { return ScrollKind(o.Which) }
// RingKind returns Which as a ring kind; valid only for KindRing.
func (o *Object) RingKind() RingKind { return RingKind(o.Which) }
// WandKind returns Which as a wand kind; valid only for KindWand.
func (o *Object) WandKind() WandKind { return WandKind(o.Which) }
// WeaponKind returns Which as a weapon kind; valid only for KindWeapon.
func (o *Object) WeaponKind() WeaponKind { return WeaponKind(o.Which) }
// ArmorKind returns Which as an armor kind; valid only for KindArmor.
func (o *Object) ArmorKind() ArmorKind { return ArmorKind(o.Which) }
// hasValidWhich reports whether Which is a legal index into this
// object's per-kind tables. Objects the game builds itself always
// satisfy it; the wizard-create command and a restored save file are the
// only two ways a malformed one can appear, and both reject it (see
// createObj and validateSnapshotObjects).
//
// The Which >= 0 arm is unreachable from the keyboard: createObj derives
// Which with byte arithmetic (int(ch-'a') + 10), which wraps to a large
// positive value rather than going negative. It is kept as
// defense-in-depth for the non-keyboard source — a decoded save file,
// where Which is an int off the wire and can hold anything — and is
// exercised there by TestRestoreRejectsOutOfRangeWhich.
func (o *Object) hasValidWhich() bool {
limit := whichLimit(o.Kind)
return limit == 0 || (o.Which >= 0 && o.Which < limit)
}
// wizardCanCreate reports whether Which names something the wizard-create
// command can actually build. It is hasValidWhich narrowed for weapons:
// WeaponFlame owns a name-table slot but no init_dam[] row, so asking for
// it would leave initWeapon nothing to copy.
func (o *Object) wizardCanCreate() bool {
if o.Kind == KindWeapon {
return o.Which >= 0 && o.Which < int(NumWeaponTypes)
}
return o.hasValidWhich()
}
// attachObj is list.c attach(): push item onto the front of a list.
func attachObj(list *[]*Object, item *Object) {
*list = append([]*Object{item}, *list...)
}
// detachObj is list.c detach(): remove item (by identity) from a list.
func detachObj(list *[]*Object, item *Object) {
for i, o := range *list {
if o == item {
*list = append((*list)[:i], (*list)[i+1:]...)
return
}
}
}

459
game/options.go Normal file
View File

@@ -0,0 +1,459 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import "strings"
// options.c — the option command and ROGUEOPTS parsing.
// optKind selects the get/put behavior of an option (the C function
// pointers o_putfunc/o_getfunc).
type optKind int
const (
optBool optKind = iota
optSeeFloor
optInvT
optStr
)
// optDesc describes an option (options.c OPTION).
type optDesc struct {
name string
prompt string
kind optKind
boolP *bool
intP *int
strP *string
}
// optList builds the options.c optlist for this game.
func (g *RogueGame) optList() []optDesc {
o := &g.Options
return []optDesc{
{"terse", "Terse output", optBool, &o.Terse, nil, nil},
{"flush", "Flush typeahead during battle", optBool, &o.FightFlush, nil, nil},
{"jump", "Show position only at end of run", optBool, &o.Jump, nil, nil},
{"seefloor", "Show the lamp-illuminated floor", optSeeFloor, &o.SeeFloor, nil, nil},
{"passgo", "Follow turnings in passageways", optBool, &o.PassGo, nil, nil},
{"tombstone", "Print out tombstone when killed", optBool, &o.Tombstone, nil, nil},
{"inven", "Inventory style", optInvT, nil, &o.InvType, nil},
{"name", "Name", optStr, nil, nil, &g.Whoami},
{"fruit", "Fruit", optStr, nil, nil, &g.Fruit},
{"file", "Save file", optStr, nil, nil, &g.FileName},
}
}
// option prints and then sets options from the terminal (options.c option).
func (g *RogueGame) option() {
hw := g.scr.Hw
optlist := g.optList()
hw.Clear()
// Display current values of options
for i := range optlist {
op := &optlist[i]
g.prOptname(op)
g.putOpt(op)
hw.AddCh('\n')
}
// Set values
hw.Move(0, 0)
for i := 0; i < len(optlist); i++ {
op := &optlist[i]
g.prOptname(op)
retval := g.getOpt(op)
if retval != Norm {
if retval == Quit {
break
}
// MINUS: back up one option
if i > 0 {
hw.Move(i-1, 0)
i -= 2
} else { // trying to back up beyond the top
hw.Move(0, 0)
i--
}
}
}
// Switch back to original screen
hw.MvAddStr(NumLines-1, 0, "--Press space to continue--")
g.scr.RefreshWin(hw)
g.waitFor(' ')
g.refresh()
g.After = false
}
// prOptname prints out the option name prompt (options.c pr_optname).
func (g *RogueGame) prOptname(op *optDesc) {
g.scr.Hw.Printwf("%s (\"%s\"): ", op.prompt, op.name)
}
// putOpt prints an option's current value (options.c put_bool/put_str/
// put_inv_t).
func (g *RogueGame) putOpt(op *optDesc) {
hw := g.scr.Hw
switch op.kind {
case optBool, optSeeFloor:
hw.AddStr(boolStr(*op.boolP))
case optInvT:
hw.AddStr(g.data.invTName[*op.intP])
case optStr:
hw.AddStr(*op.strP)
}
}
func boolStr(b bool) string {
if b {
return "True"
}
return "False"
}
// getOpt reads a new value for an option (options.c get_bool/get_sf/
// get_inv_t/get_str dispatch).
func (g *RogueGame) getOpt(op *optDesc) int {
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch op.kind {
case optBool:
return g.getBool(op.boolP)
case optSeeFloor:
return g.getSf(op.boolP)
case optInvT:
return g.getInvT(op.intP)
default:
return g.getStr(op.strP, g.scr.Hw)
}
}
// getBool allows changing a boolean option and prints it out (options.c
// get_bool).
func (g *RogueGame) getBool(bp *bool) int {
win := g.scr.Hw
oy, ox := win.GetYX()
win.AddStr(boolStr(*bp))
for {
win.Move(oy, ox)
g.scr.RefreshWin(win)
switch g.readchar() {
case 't', 'T':
*bp = true
case 'f', 'F':
*bp = false
case '\n', '\r':
case Escape:
return Quit
case '-':
return Minus
default:
win.Move(oy, ox+10)
win.AddStr("(T or F)")
continue
}
break
}
win.Move(oy, ox)
win.AddStr(boolStr(*bp))
win.AddCh('\n')
return Norm
}
// getSf changes see_floor and handles the display transition (options.c
// get_sf).
func (g *RogueGame) getSf(bp *bool) int {
wasSf := g.Options.SeeFloor
retval := g.getBool(bp)
if retval == Quit {
return Quit
}
if wasSf != g.Options.SeeFloor {
if !g.Options.SeeFloor {
g.Options.SeeFloor = true
g.eraseLamp(g.Player.Pos, g.Player.Room)
g.Options.SeeFloor = false
} else {
g.look(false)
}
}
return Norm
}
// getStr sets a string option (options.c get_str). win selects between the
// options window and the message line (C's stdscr), which changes the '-'
// and mpos behavior.
func (g *RogueGame) getStr(opt *string, win *Window) int {
onStd := win == g.scr.Std
oy, ox := win.GetYX()
g.scr.RefreshWin(win)
// loop reading in the string, and put it in a temporary buffer
var (
buf []byte
c byte
)
for {
c = g.readchar()
if endsInput(c) || (len(buf) == 0 && c == '-' && !onStd) {
break
}
buf = g.getStrEdit(win, buf, c, oy, ox)
}
if len(buf) > 0 { // only change option if something has been typed
*opt = strucpy(string(buf))
}
win.MvPrintwf(oy, ox, "%s\n", *opt)
g.scr.RefreshWin(win)
if onStd {
g.Msgs.Mpos += len(buf)
}
return getStrResult(c)
}
// getStrResult maps the terminating key to the C return code (options.c
// get_str).
func getStrResult(c byte) int {
switch c {
case '-':
return Minus
case Escape:
return Quit
default:
return Norm
}
}
// endsInput reports the keys that finish line input (options.c get_str).
func endsInput(c byte) bool {
return c == '\n' || c == '\r' || c == Escape
}
// getStrErase deletes the last character of the buffer (options.c
// get_str).
func getStrErase(win *Window, buf []byte, oy, ox int) []byte {
if len(buf) > 0 {
buf = buf[:len(buf)-1]
win.Move(oy, ox+len(displayStr(buf)))
}
win.Clrtoeol()
return buf
}
// getStrEdit applies one key to the line editor's buffer: erase, kill,
// home expansion, or a typed character (options.c get_str).
func (g *RogueGame) getStrEdit(win *Window, buf []byte, c byte, oy, ox int) []byte {
switch {
case c == 8 || c == 0x7f:
buf = getStrErase(win, buf, oy, ox)
case c == CTRL('U'): // kill character
buf = buf[:0]
win.Move(oy, ox)
win.Clrtoeol()
case len(buf) == 0 && c == '~':
buf = append(buf, g.Home...)
win.AddStr(g.Home)
win.Clrtoeol()
case len(buf) >= MaxInp || (!isPrint(c) && c != ' '):
return buf // C beeps here
default:
buf = append(buf, c)
win.AddStr(unctrl(c))
win.Clrtoeol()
}
g.scr.RefreshWin(win)
return buf
}
// displayStr renders a buffer the way the input echo did.
func displayStr(buf []byte) string {
var sb strings.Builder
for _, c := range buf {
sb.WriteString(unctrl(c))
}
return sb.String()
}
// getInvT gets an inventory type name (options.c get_inv_t).
func (g *RogueGame) getInvT(ip *int) int {
win := g.scr.Hw
oy, ox := win.GetYX()
win.AddStr(g.data.invTName[*ip])
for {
win.Move(oy, ox)
g.scr.RefreshWin(win)
switch g.readchar() {
case 'o', 'O':
*ip = InvOver
case 's', 'S':
*ip = InvSlow
case 'c', 'C':
*ip = InvClear
case '\n', '\r':
case Escape:
return Quit
case '-':
return Minus
default:
win.Move(oy, ox+15)
win.AddStr("(O, S, or C)")
continue
}
break
}
win.MvPrintwf(oy, ox, "%s\n", g.data.invTName[*ip])
return Norm
}
// ParseOpts parses options from a string, usually taken from the
// environment: comma separated values, booleans stated as "name" (true) or
// "noname" (false), strings as "name=..." (options.c parse_opts).
func (g *RogueGame) ParseOpts(str string) {
optlist := g.optList()
for str != "" {
// Get option name
i := 0
for i < len(str) && isAlpha(str[i]) {
i++
}
rest := g.parseOptName(optlist, str[:i], str[i:])
// skip to start of next option name
for rest != "" && !isAlpha(rest[0]) {
rest = rest[1:]
}
str = rest
}
}
// parseOptName applies one named option, returning the unconsumed
// remainder: "name" turns a boolean on, "noname" turns it off, and
// string options consume a value (the option scan of options.c
// parse_opts).
func (g *RogueGame) parseOptName(optlist []optDesc, name, rest string) string {
for oi := range optlist {
op := &optlist[oi]
isBoolOpt := op.kind == optBool || op.kind == optSeeFloor
if strings.HasPrefix(op.name, name) && name != "" {
if isBoolOpt {
*op.boolP = true
return rest
}
return g.parseOptValue(op, rest)
}
if isBoolOpt && strings.HasPrefix(name, "no") &&
strings.HasPrefix(op.name, name[2:]) {
*op.boolP = false
return rest
}
}
return rest
}
// parseOptValue consumes an option's "=value" from rest, storing it,
// and returns the remainder (the string arm of options.c parse_opts).
func (g *RogueGame) parseOptValue(op *optDesc, rest string) string {
// Skip to start of string value
for rest != "" && rest[0] == '=' {
rest = rest[1:]
}
val := rest
var prefix string
if val != "" && val[0] == '~' {
prefix = g.Home
val = val[1:]
for val != "" && val[0] == '/' {
val = val[1:]
}
}
// Skip to end of string value
end := strings.IndexByte(val, ',')
if end < 0 {
end = len(val)
}
word := val[:end]
if op.kind == optInvT {
g.parseInvType(op, word)
} else {
*op.strP = prefix + strucpy(word)
}
return val[end:]
}
// parseInvType matches an inventory-style name by prefix (options.c
// parse_opts).
func (g *RogueGame) parseInvType(op *optDesc, word string) {
// check for type of inventory
if word != "" {
word = string(toUpper(word[0])) + word[1:]
}
for ti, tn := range g.data.invTName {
if strings.HasPrefix(tn, word) {
*op.intP = ti
break
}
}
}
// strucpy copies a string keeping only printable characters, capped at
// MAXINP (options.c strucpy).
func strucpy(s string) string {
if len(s) > MaxInp {
s = s[:MaxInp]
}
var sb strings.Builder
for i := range len(s) {
if isPrint(s[i]) || s[i] == ' ' {
sb.WriteByte(s[i])
}
}
return sb.String()
}

540
game/pack.go Normal file
View File

@@ -0,0 +1,540 @@
package game
// pack.c — routines to deal with the pack.
// addPack picks up an object and adds it to the pack; if obj is non-nil use
// it instead of getting it off the ground (pack.c add_pack).
func (g *RogueGame) addPack(obj *Object, silent bool) {
p := &g.Player
fromFloor := false
if obj == nil {
if obj = g.Level.ObjectAt(p.Pos.Y, p.Pos.X); obj == nil {
return
}
fromFloor = true
}
// Check for and deal with scare monster scrolls
if g.pickupScareScroll(obj) {
return
}
obj, ok := g.packInsert(obj, fromFloor)
if !ok {
return
}
obj.Flags.Set(WasFound)
// If this was the object of something's desire, that monster will get
// mad and run at the hero.
for _, op := range g.Level.Monsters {
if op.Dest == &obj.Pos {
op.Dest = &p.Pos
}
}
if obj.Kind == KindAmulet {
g.HasAmulet = true
}
// Notify the user
if !silent {
if !g.Options.Terse {
g.addmsgf("you now have ")
}
g.msg("%s (%c)", g.inventoryName(obj, !g.Options.Terse), obj.PackCh)
}
}
// pickupScareScroll crumbles a found scare monster scroll when it is
// picked up again; it reports whether it did (pack.c add_pack).
func (g *RogueGame) pickupScareScroll(obj *Object) bool {
if obj.Kind != KindScroll || obj.ScrollKind() != ScrollScareMonster ||
!obj.Flags.Has(WasFound) {
return false
}
p := &g.Player
g.Level.RemoveObject(obj)
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh())
if p.Room.Flags.Has(Gone) {
g.Level.SetChar(p.Pos.Y, p.Pos.X, Passage)
} else {
g.Level.SetChar(p.Pos.Y, p.Pos.X, Floor)
}
g.msg("the scroll turns to dust as you pick it up")
return true
}
// packInsert places the object in the pack, keeping items of one type
// together and merging stackable and grouped items — a translation of
// the C linked-list walk in pack.c add_pack. It returns the pack entry
// the object ended up as; ok is false when the pack has no room.
func (g *RogueGame) packInsert(obj *Object, fromFloor bool) (*Object, bool) {
p := &g.Player
if len(p.Pack) == 0 {
p.Pack = append(p.Pack, obj)
obj.PackCh = p.nextPackChar()
p.Inpack++
return obj, true
}
merged := false
// lp is the index to insert after; -1 after a merge means no
// insertion.
i, lp := packScanKind(p.Pack, obj.Kind)
if i < len(p.Pack) {
i, lp = packScanWhich(p.Pack, obj, i, lp)
if op := p.Pack[i]; op.Kind == obj.Kind && op.Which == obj.Which {
var ok bool
obj, lp, merged, ok = g.packMatch(obj, op, i, fromFloor)
if !ok {
return nil, false
}
}
}
if !merged && lp != -1 {
if !g.packRoom(fromFloor, obj) {
return nil, false
}
obj.PackCh = p.nextPackChar()
p.Pack = append(p.Pack[:lp+1],
append([]*Object{obj}, p.Pack[lp+1:]...)...)
}
return obj, true
}
// packScanKind scans to the first pack entry of this kind, returning
// its index (or the pack length) and the entry to insert after (the
// outer scan of pack.c add_pack).
func packScanKind(pack []*Object, kind ObjectKind) (int, int) {
lp := -1
i := 0
for ; i < len(pack); i++ {
if pack[i].Kind == kind {
break
}
lp = i
}
return i, lp
}
// packScanWhich scans within the kind group for the matching subtype
// (the inner scan of pack.c add_pack).
func packScanWhich(pack []*Object, obj *Object, i, lp int) (int, int) {
for pack[i].Kind == obj.Kind && pack[i].Which != obj.Which {
lp = i
if i+1 >= len(pack) {
break
}
i++
}
return i, lp
}
// packMatch merges the object with a matching pack entry when possible:
// stackables merge counts, grouped missiles rejoin their bundle, and
// anything else marks the insertion point (the matched-subtype switch
// of pack.c add_pack). It returns the resulting entry, the insert-after
// index, whether a merge happened, and ok false when the pack is full.
func (g *RogueGame) packMatch(
obj, op *Object, i int, fromFloor bool,
) (*Object, int, bool, bool) {
switch {
case op.Kind.MergesInPack():
if !g.packRoom(fromFloor, obj) {
return nil, 0, false, false
}
op.Count++
return op, -1, true, true
case obj.Group != 0:
return g.packMatchGroup(obj, i, fromFloor)
default:
return obj, i, false, true
}
}
// packMatchGroup rejoins a grouped missile bundle with its group entry
// (the o_group arm of pack.c add_pack).
func (g *RogueGame) packMatchGroup(
obj *Object, i int, fromFloor bool,
) (*Object, int, bool, bool) {
p := &g.Player
lp := i
for p.Pack[i].Kind == obj.Kind &&
p.Pack[i].Which == obj.Which &&
p.Pack[i].Group != obj.Group {
lp = i
if i+1 >= len(p.Pack) {
break
}
i++
}
op := p.Pack[i]
if op.Kind == obj.Kind && op.Which == obj.Which &&
op.Group == obj.Group {
op.Count += obj.Count
p.Inpack--
if !g.packRoom(fromFloor, obj) {
return nil, 0, false, false
}
return op, -1, true, true
}
return obj, lp, false, true
}
// packRoom sees if there's room in the pack; if not, prints an appropriate
// message (pack.c pack_room).
func (g *RogueGame) packRoom(fromFloor bool, obj *Object) bool {
p := &g.Player
if p.Inpack++; p.Inpack > MaxPack {
if !g.Options.Terse {
g.addmsgf("there's ")
}
g.addmsgf("no room")
if !g.Options.Terse {
g.addmsgf(" in your pack")
}
g.endmsg()
if fromFloor {
g.moveMsg(obj)
}
p.Inpack = MaxPack
return false
}
if fromFloor {
g.Level.RemoveObject(obj)
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh())
if p.Room.Flags.Has(Gone) {
g.Level.SetChar(p.Pos.Y, p.Pos.X, Passage)
} else {
g.Level.SetChar(p.Pos.Y, p.Pos.X, Floor)
}
}
return true
}
// leavePack takes an item out of the pack (pack.c leave_pack), keeping
// the repeat-command bookkeeping; the pack surgery is
// Player.removeFromPack.
func (g *RogueGame) leavePack(obj *Object, newobj, all bool) *Object {
if obj.Count > 1 && !all {
g.LastPick = obj
} else {
g.LastPick = nil
}
return g.Player.removeFromPack(obj, newobj, all)
}
// inventory lists what is in the pack; returns true if there is something
// of the given type (pack.c inventory).
func (g *RogueGame) inventory(list []*Object, kind ObjectKind) bool {
g.NObjs = 0
for _, item := range list {
if !matchesFilter(kind, item) {
continue
}
g.NObjs++
g.Msgs.MsgEsc = true
line := string(item.PackCh) + ") " + g.inventoryName(item, false)
if g.addLine("%s", line) == Escape {
g.Msgs.MsgEsc = false
g.msg("")
return true
}
g.Msgs.MsgEsc = false
}
if g.NObjs == 0 {
if kind == KindNone {
g.msg("%s", g.chooseTerse("empty handed", "you are empty handed"))
} else {
g.msg("%s", g.chooseTerse("nothing appropriate",
"you don't have anything appropriate"))
}
return false
}
g.endLine()
return true
}
// pickUp adds something to the character's pack (pack.c pick_up).
func (g *RogueGame) pickUp(ch byte) {
p := &g.Player
if p.On(Levitating) {
return
}
obj := g.Level.ObjectAt(p.Pos.Y, p.Pos.X)
if g.MoveOn {
g.moveMsg(obj)
return
}
switch ch {
case Gold:
if obj == nil {
return
}
g.money(obj.GoldValue)
g.Level.RemoveObject(obj)
p.Room.GoldVal = 0
default:
g.addPack(nil, false)
}
}
// matchesFilter reports whether an item passes a get_item/inventory kind
// filter (the C condition in pack.c inventory, untangled): KindNone takes
// everything, KindCallable takes anything nameable (not food, not the
// amulet), KindRingOrStick takes rings and wands.
func matchesFilter(kind ObjectKind, item *Object) bool {
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch kind {
case KindNone:
return true
case KindCallable:
return item.Kind != KindFood && item.Kind != KindAmulet
case KindRingOrStick:
return item.Kind == KindRing || item.Kind == KindWand
default:
return item.Kind == kind
}
}
// moveMsg prints the message if you are just moving onto an object
// (pack.c move_msg).
func (g *RogueGame) moveMsg(obj *Object) {
if !g.Options.Terse {
g.addmsgf("you ")
}
g.msg("moved onto %s", g.inventoryName(obj, true))
}
// pickyInven allows the player to inventory a single item (pack.c
// picky_inven).
func (g *RogueGame) pickyInven() {
p := &g.Player
if len(p.Pack) == 0 {
g.msg("you aren't carrying anything")
return
}
if len(p.Pack) == 1 {
g.msg("a) %s", g.inventoryName(p.Pack[0], false))
return
}
g.msg("%s", g.chooseTerse("item: ", "which item do you wish to inventory: "))
g.Msgs.Mpos = 0
mch := g.readchar()
if mch == Escape {
g.msg("")
return
}
for _, obj := range p.Pack {
if mch == obj.PackCh {
g.msg("%c) %s", mch, g.inventoryName(obj, false))
return
}
}
g.msg("'%s' not in pack", unctrl(mch))
}
// promptPackItem picks something out of a pack for a purpose (pack.c
// get_item); the second result reports whether the player chose an
// item.
func (g *RogueGame) promptPackItem(purpose string, kind ObjectKind) (*Object, bool) {
p := &g.Player
if len(p.Pack) == 0 {
g.msg("you aren't carrying anything")
return nil, false
}
if g.Again {
return g.repeatLastItem()
}
for {
g.promptItemPurpose(purpose)
ch := g.readchar()
g.Msgs.Mpos = 0
// Give the poor player a chance to abort the command
if ch == Escape {
g.resetLast()
g.After = false
g.msg("")
return nil, false
}
g.NObjs = 1 // normal case: person types one char
if ch == '*' {
g.Msgs.Mpos = 0
if !g.inventory(p.Pack, kind) {
g.After = false
return nil, false
}
continue
}
for _, obj := range p.Pack {
if obj.PackCh == ch {
return obj, true
}
}
g.msg("'%s' is not a valid item", unctrl(ch))
}
}
// repeatLastItem replays the previous selection for the repeat command
// (pack.c get_item).
func (g *RogueGame) repeatLastItem() (*Object, bool) {
if g.LastPick != nil {
return g.LastPick, true
}
g.msg("you ran out")
return nil, false
}
// promptItemPurpose prints the "which object do you want to ...?"
// prompt (pack.c get_item).
func (g *RogueGame) promptItemPurpose(purpose string) {
if !g.Options.Terse {
g.addmsgf("which object do you want to ")
}
g.addmsgf("%s", purpose)
if g.Options.Terse {
g.addmsgf(" what")
}
g.msg("? (* for list): ")
}
// money adds or subtracts gold from the pack (pack.c money).
func (g *RogueGame) money(value int) {
p := &g.Player
p.Purse += value
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh())
if p.Room.Flags.Has(Gone) {
g.Level.SetChar(p.Pos.Y, p.Pos.X, Passage)
} else {
g.Level.SetChar(p.Pos.Y, p.Pos.X, Floor)
}
if value > 0 {
if !g.Options.Terse {
g.addmsgf("you found ")
}
g.msg("%d gold pieces", value)
}
}
// floorCh returns the appropriate floor character for her room
// (pack.c floor_ch).
func (g *RogueGame) floorCh() byte {
if g.Player.Room.Flags.Has(Gone) {
return Passage
}
if g.showFloor() {
return Floor
}
return ' '
}
// floorAt returns the character at the hero's position, taking see_floor
// into account (pack.c floor_at).
func (g *RogueGame) floorAt() byte {
ch := g.Level.Char(g.Player.Pos.Y, g.Player.Pos.X)
if ch == Floor {
ch = g.floorCh()
}
return ch
}
// resetLast resets the last command when the current one is aborted
// (pack.c reset_last).
func (g *RogueGame) resetLast() {
g.LastComm = g.LLastComm
g.LastDir = g.LLastDir
g.LastPick = g.LLastPick
}
// chooseTerse picks the terse or verbose variant of a message.
func (g *RogueGame) chooseTerse(terse, verbose string) string {
if g.Options.Terse {
return terse
}
return verbose
}

406
game/passages.go Normal file
View File

@@ -0,0 +1,406 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// passages.c — draw the connecting passages.
// digPassages draws all the passages on a level (passages.c do_passages).
func (g *RogueGame) digPassages() {
var (
isconn [MaxRooms][MaxRooms]bool
ingraph [MaxRooms]bool
)
// starting with one room, connect it to a random adjacent room and
// then pick a new room to start with.
roomcount := 1
r1 := g.rnd(MaxRooms)
ingraph[r1] = true
for roomcount < MaxRooms {
// find a room to connect with
r2 := g.pickNeighbor(r1, func(i int) bool { return !ingraph[i] })
if r2 < 0 {
// if no adjacent rooms are outside the graph, pick a new
// room to look from
for {
r1 = g.rnd(MaxRooms)
if ingraph[r1] {
break
}
}
continue
}
// otherwise, connect new room to the graph, and draw a tunnel
// to it
ingraph[r2] = true
g.connectRooms(r1, r2)
isconn[r1][r2] = true
isconn[r2][r1] = true
roomcount++
}
// attempt to add passages to the graph a random number of times so that
// there isn't always just one unique passage through it.
for roomcount = g.rnd(5); roomcount > 0; roomcount-- {
r1 = g.rnd(MaxRooms) // a random room to look from
// find an adjacent room not already connected; if there is one,
// connect it and look for the next added passage
r2 := g.pickNeighbor(r1, func(i int) bool { return !isconn[r1][i] })
if r2 >= 0 {
g.connectRooms(r1, r2)
isconn[r1][r2] = true
isconn[r2][r1] = true
}
}
g.numberPassages()
}
// pickNeighbor reservoir-picks an adjacent room for which ok holds, or
// -1 when there is none (passages.c do_passages).
func (g *RogueGame) pickNeighbor(r1 int, ok func(int) bool) int {
j := 0
r2 := -1
for i := range MaxRooms {
if g.data.rdesConn[r1][i] && ok(i) {
if j++; g.rnd(j) == 0 {
r2 = i
}
}
}
return r2
}
// corridorPlan is the movement setup connectRooms computes before it
// digs (the local variables of passages.c conn).
type corridorPlan struct {
rpf, rpt *Room // the rooms being joined
del Coord // direction of move
turnDelta Coord // direction to turn
spos, epos Coord // start and end of move
distance, turnDistance int // how far to move and to turn
}
// connectRooms draws a corridor from a room in a certain direction
// (passages.c conn).
func (g *RogueGame) connectRooms(r1, r2 int) {
rm, direc := connOrient(r1, r2)
var plan corridorPlan
if direc == 'd' {
plan = g.connPlanDown(rm)
} else {
plan = g.connPlanRight(rm)
}
turnSpot := g.rnd(plan.distance-1) + 1 // where turn starts
// Draw in the doors on either side of the passage or just put #'s if
// the rooms are gone.
g.connEnd(plan.rpf, plan.spos)
g.connEnd(plan.rpt, plan.epos)
g.digCorridor(plan, turnSpot)
}
// connOrient picks the upper-left room of the pair and the digging
// direction: right for horizontal neighbors, down otherwise (passages.c
// conn).
func connOrient(r1, r2 int) (int, byte) {
rm := min(r1, r2)
if abs(r1-r2) == 1 {
return rm, 'r'
}
return rm, 'd'
}
// connPlanDown sets up the movement variables for a corridor drawn
// downward (passages.c conn).
func (g *RogueGame) connPlanDown(rm int) corridorPlan {
rpf := &g.Level.Rooms[rm]
rpt := &g.Level.Rooms[rm+3] // room pointer of dest
plan := corridorPlan{
rpf: rpf,
rpt: rpt,
del: Coord{X: 0, Y: 1}, // direction of move
spos: rpf.Pos, // start of move
epos: rpt.Pos, // end of move
}
if !rpf.Flags.Has(Gone) { // if not gone pick door pos
for {
plan.spos.X = rpf.Pos.X + g.rnd(rpf.Max.X-2) + 1
plan.spos.Y = rpf.Pos.Y + rpf.Max.Y - 1
if !rpf.Flags.Has(Maze) ||
g.Level.FlagsAt(plan.spos.Y, plan.spos.X).Has(FPassage) {
break
}
}
}
if !rpt.Flags.Has(Gone) {
for {
plan.epos.X = rpt.Pos.X + g.rnd(rpt.Max.X-2) + 1
if !rpt.Flags.Has(Maze) ||
g.Level.FlagsAt(plan.epos.Y, plan.epos.X).Has(FPassage) {
break
}
}
}
plan.distance = abs(plan.spos.Y-plan.epos.Y) - 1 // distance to move
plan.turnDelta.Y = 0 // direction to turn
if plan.spos.X < plan.epos.X {
plan.turnDelta.X = 1
} else {
plan.turnDelta.X = -1
}
plan.turnDistance = abs(plan.spos.X - plan.epos.X) // how far to turn
return plan
}
// connPlanRight sets up the movement variables for a corridor drawn to
// the right (passages.c conn).
func (g *RogueGame) connPlanRight(rm int) corridorPlan {
rpf := &g.Level.Rooms[rm]
rpt := &g.Level.Rooms[rm+1]
plan := corridorPlan{
rpf: rpf,
rpt: rpt,
del: Coord{X: 1, Y: 0},
spos: rpf.Pos,
epos: rpt.Pos,
}
if !rpf.Flags.Has(Gone) {
for {
plan.spos.X = rpf.Pos.X + rpf.Max.X - 1
plan.spos.Y = rpf.Pos.Y + g.rnd(rpf.Max.Y-2) + 1
if !rpf.Flags.Has(Maze) ||
g.Level.FlagsAt(plan.spos.Y, plan.spos.X).Has(FPassage) {
break
}
}
}
if !rpt.Flags.Has(Gone) {
for {
plan.epos.Y = rpt.Pos.Y + g.rnd(rpt.Max.Y-2) + 1
if !rpt.Flags.Has(Maze) ||
g.Level.FlagsAt(plan.epos.Y, plan.epos.X).Has(FPassage) {
break
}
}
}
plan.distance = abs(plan.spos.X-plan.epos.X) - 1
if plan.spos.Y < plan.epos.Y {
plan.turnDelta.Y = 1
} else {
plan.turnDelta.Y = -1
}
plan.turnDelta.X = 0
plan.turnDistance = abs(plan.spos.Y - plan.epos.Y)
return plan
}
// connEnd draws a corridor end: a door on a real room, a passage square
// on a gone one (passages.c conn).
func (g *RogueGame) connEnd(rp *Room, pos Coord) {
if !rp.Flags.Has(Gone) {
g.door(rp, pos)
} else {
g.putPassage(pos)
}
}
// digCorridor digs from spos to epos, turning at turnSpot (the digging
// loop of passages.c conn).
func (g *RogueGame) digCorridor(plan corridorPlan, turnSpot int) {
curr := plan.spos
distance := plan.distance
turnDistance := plan.turnDistance
for distance > 0 {
// Move to new position
curr.X += plan.del.X
curr.Y += plan.del.Y
// Check if we are at the turn place, if so do the turn
if distance == turnSpot {
for ; turnDistance > 0; turnDistance-- {
g.putPassage(curr)
curr.X += plan.turnDelta.X
curr.Y += plan.turnDelta.Y
}
}
// Continue digging along
g.putPassage(curr)
distance--
}
curr.X += plan.del.X
curr.Y += plan.del.Y
if curr != plan.epos {
g.msg("warning, connectivity problem on this level")
}
}
// putPassage adds a passage character or secret passage here (passages.c
// putpass).
func (g *RogueGame) putPassage(cp Coord) {
pp := g.Level.At(cp.Y, cp.X)
pp.Flags.Set(FPassage)
if g.rnd(10)+1 < g.Depth && g.rnd(40) == 0 {
pp.Flags.Clear(FReal)
} else {
pp.Ch = Passage
}
}
// door adds a door or possibly a secret door; also enters the door in the
// exits array of the room (passages.c door).
func (g *RogueGame) door(rm *Room, cp Coord) {
rm.Exits = append(rm.Exits, cp)
if rm.Flags.Has(Maze) {
return
}
pp := g.Level.At(cp.Y, cp.X)
if g.rnd(10)+1 < g.Depth && g.rnd(5) == 0 {
if cp.Y == rm.Pos.Y || cp.Y == rm.Pos.Y+rm.Max.Y-1 {
pp.Ch = '-'
} else {
pp.Ch = '|'
}
pp.Flags.Clear(FReal)
} else {
pp.Ch = Door
}
}
// addPass adds the passages to the current window — wizard command
// (passages.c add_pass).
func (g *RogueGame) addPass() {
for y := 1; y < NumLines-1; y++ {
for x := range NumCols {
g.addPassSpot(g.Level.At(y, x), y, x)
}
}
}
// addPassSpot shows one passage or door square for the wizard (the loop
// body of passages.c add_pass).
func (g *RogueGame) addPassSpot(pp *Place, y, x int) {
if !pp.Flags.Has(FPassage) && !hiddenExit(pp.Flags, pp.Ch) {
return
}
ch := pp.Ch
if pp.Flags.Has(FPassage) {
ch = Passage
}
pp.Flags.Set(FSeen)
g.move(y, x)
switch {
case pp.Monst != nil:
pp.Monst.OldCh = pp.Ch
case pp.Flags.Has(FReal):
g.addch(ch)
default:
g.standout()
if pp.Flags.Has(FPassage) {
g.addch(Passage)
} else {
g.addch(Door)
}
g.standend()
}
}
// hiddenExit reports a door, or a secret door still drawn as a wall
// (passages.c add_pass / numpass).
func hiddenExit(fp PlaceFlags, ch byte) bool {
return ch == Door || (!fp.Has(FReal) && (ch == '|' || ch == '-'))
}
// numberPassages assigns a number to each passageway (passages.c passnum).
func (g *RogueGame) numberPassages() {
g.pnum = 0
g.newpnum = false
for i := range g.Level.Passages {
g.Level.Passages[i].Exits = g.Level.Passages[i].Exits[:0]
}
for i := range g.Level.Rooms {
rp := &g.Level.Rooms[i]
for j := range rp.Exits {
g.newpnum = true
g.numberPassage(rp.Exits[j].Y, rp.Exits[j].X)
}
}
}
// numberPassage numbers a passageway square and its brethren (passages.c
// numpass).
func (g *RogueGame) numberPassage(y, x int) {
if x >= NumCols || x < 0 || y >= NumLines || y <= 0 {
return
}
fp := g.Level.FlagsAt(y, x)
if fp.Has(FPassNum) {
return
}
if g.newpnum {
g.pnum++
g.newpnum = false
}
// check to see if it is a door or secret door, i.e., a new exit, or a
// numerable type of place
if hiddenExit(*fp, g.Level.Char(y, x)) {
rp := &g.Level.Passages[g.pnum]
rp.Exits = append(rp.Exits, Coord{Y: y, X: x})
} else if !fp.Has(FPassage) {
return
}
*fp |= PlaceFlags(g.pnum) //nolint:gosec // G115: pnum < MaxPass=13
// recurse on the surrounding places
g.numberPassage(y+1, x)
g.numberPassage(y-1, x)
g.numberPassage(y, x+1)
g.numberPassage(y, x-1)
}
// abs is C abs() for ints.
func abs(n int) int {
if n < 0 {
return -n
}
return n
}

368
game/potions.go Normal file
View File

@@ -0,0 +1,368 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import "fmt"
// potions.c — functions for dealing with potions.
// pact describes a standard fuse-based potion (potions.c PACT).
type pact struct {
flags CreatureFlags
daemon DaemonID
time int
high string // message when hallucinating
straight string
}
// quaff drinks a potion from the pack (potions.c quaff).
func (g *RogueGame) quaff() {
p := &g.Player
obj, ok := g.promptPackItem("quaff", KindPotion)
// Make certain that it is something that we want to drink
if !ok {
return
}
if obj.Kind != KindPotion {
if !g.Options.Terse {
g.msg("yuk! Why would you want to drink that?")
} else {
g.msg("that's undrinkable")
}
return
}
if obj == p.CurWeapon {
p.CurWeapon = nil
}
// Calculate the effect it has on the poor guy.
trip := p.On(Hallucinating)
g.leavePack(obj, false, false)
if h := g.data.quaffHandler(obj); h != nil {
h(g, trip)
}
g.status()
// Throw the item away. A malformed potion has no lore entry to name,
// so it is drunk for no effect and never prompts to be called anything.
if obj.hasValidWhich() {
g.callIt(&g.Items.Potions[obj.Which])
}
}
// The per-potion effect handlers, dispatched through
// gameData.quaffHandlers. Each is one case of the C quaff switch.
func (g *RogueGame) quaffConfusion(trip bool) {
g.applyPotionFuse(PotionConfusion, !trip)
}
func (g *RogueGame) quaffPoison(bool) {
g.Items.Potions[PotionPoison].Know = true
if g.Player.IsWearing(RingSustainStrength) {
g.msg("you feel momentarily sick")
} else {
g.changeStrength(-(g.rnd(3) + 1))
g.msg("you feel very sick now")
g.comeDown(0)
}
}
func (g *RogueGame) quaffHealing(bool) {
p := &g.Player
g.Items.Potions[PotionHealing].Know = true
if p.Stats.HP += g.roll(p.Stats.Lvl, 4); p.Stats.HP > p.Stats.MaxHP {
p.Stats.MaxHP++
p.Stats.HP = p.Stats.MaxHP
}
g.sight(0)
g.msg("you begin to feel better")
}
func (g *RogueGame) quaffGainStrength(bool) {
g.Items.Potions[PotionGainStrength].Know = true
g.changeStrength(1)
g.msg("you feel stronger, now. What bulging muscles!")
}
func (g *RogueGame) quaffDetectMonsters(bool) {
g.Player.Flags.Set(SenseMonsters)
g.Fuse(DTurnSee, 1, HuhDuration, After)
if !g.turnSee(false) {
g.msg("you have a %s feeling for a moment, then it passes",
g.chooseStr("normal", "strange"))
}
}
func (g *RogueGame) quaffDetectMagic(bool) {
// Potion of magic detection. Show the potions and scrolls
show := false
if len(g.Level.Objects) > 0 {
g.scr.Hw.Clear()
for _, tp := range g.Level.Objects {
if g.isMagic(tp) {
show = true
g.scr.Hw.MvAddCh(tp.Pos.Y, tp.Pos.X, Magic)
g.Items.Potions[PotionDetectMagic].Know = true
}
}
for _, mp := range g.Level.Monsters {
for _, tp := range mp.Pack {
if g.isMagic(tp) {
show = true
g.scr.Hw.MvAddCh(mp.Pos.Y, mp.Pos.X, Magic)
}
}
}
}
if show {
g.Items.Potions[PotionDetectMagic].Know = true
g.showWin("You sense the presence of magic on this level.--More--")
} else {
g.msg("you have a %s feeling for a moment, then it passes",
g.chooseStr("normal", "strange"))
}
}
func (g *RogueGame) quaffLSD(trip bool) {
p := &g.Player
if !trip {
if p.On(SenseMonsters) {
g.turnSee(false)
}
g.StartDaemon(DVisuals, 0, Before)
g.SeenStairs = g.seenStairs()
}
g.applyPotionFuse(PotionLSD, true)
}
func (g *RogueGame) quaffSeeInvisible(bool) {
show := g.Player.On(CanSeeInvisible)
g.applyPotionFuse(PotionSeeInvisible, false)
if !show {
g.invisOn()
}
g.sight(0)
}
func (g *RogueGame) quaffRaiseLevel(bool) {
g.Items.Potions[PotionRaiseLevel].Know = true
g.msg("you suddenly feel much more skillful")
g.raiseLevel()
}
func (g *RogueGame) quaffExtraHealing(bool) {
p := &g.Player
g.Items.Potions[PotionExtraHealing].Know = true
if p.Stats.HP += g.roll(p.Stats.Lvl, 8); p.Stats.HP > p.Stats.MaxHP {
if p.Stats.HP > p.Stats.MaxHP+p.Stats.Lvl+1 {
p.Stats.MaxHP++
}
p.Stats.MaxHP++
p.Stats.HP = p.Stats.MaxHP
}
g.sight(0)
g.comeDown(0)
g.msg("you begin to feel much better")
}
func (g *RogueGame) quaffHaste(bool) {
g.Items.Potions[PotionHaste].Know = true
g.After = false
if g.addHaste(true) {
g.msg("you feel yourself moving much faster")
}
}
func (g *RogueGame) quaffRestoreStrength(bool) {
p := &g.Player
if p.IsRing(Left, RingAddStrength) {
addStr(&p.Stats.Str, -p.CurRing[Left].Bonus)
}
if p.IsRing(Right, RingAddStrength) {
addStr(&p.Stats.Str, -p.CurRing[Right].Bonus)
}
if p.Stats.Str < p.MaxStats.Str {
p.Stats.Str = p.MaxStats.Str
}
if p.IsRing(Left, RingAddStrength) {
addStr(&p.Stats.Str, p.CurRing[Left].Bonus)
}
if p.IsRing(Right, RingAddStrength) {
addStr(&p.Stats.Str, p.CurRing[Right].Bonus)
}
g.msg("hey, this tastes great. It make you feel warm all over")
}
func (g *RogueGame) quaffBlindness(bool) {
g.applyPotionFuse(PotionBlindness, true)
}
func (g *RogueGame) quaffLevitation(bool) {
g.applyPotionFuse(PotionLevitation, true)
}
// raiseLevel: the guy just magically went up a level (potions.c
// raise_level).
func (g *RogueGame) raiseLevel() {
g.Player.Stats.Exp = g.data.eLevels[g.Player.Stats.Lvl-1] + 1
g.checkLevel()
}
// applyPotionFuse does a potion with standard setup: it uses a fuse and
// turns on a flag (potions.c do_pot).
func (g *RogueGame) applyPotionFuse(kind PotionKind, knowit bool) {
pp := &g.data.pActions[kind]
if !g.Items.Potions[kind].Know {
g.Items.Potions[kind].Know = knowit
}
t := g.spread(pp.time)
if !g.Player.On(pp.flags) {
g.Player.Flags.Set(pp.flags)
g.Fuse(pp.daemon, 0, t, After)
g.look(false)
} else {
g.Lengthen(pp.daemon, t)
}
high, straight := pp.high, pp.straight
if kind == PotionSeeInvisible {
s := fmt.Sprintf("this potion tastes like %s juice", g.Fruit)
high, straight = s, s
}
g.msg("%s", g.chooseStr(high, straight))
}
// isMagic reports whether an object radiates magic (potions.c is_magic).
func (g *RogueGame) isMagic(o *Object) bool {
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch o.Kind {
case KindArmor:
return o.Flags.Has(Protected) || o.ArmorClass != g.data.armorClass(o.Which)
case KindWeapon:
return o.HPlus != 0 || o.DPlus != 0
case KindPotion, KindScroll, KindWand, KindRing, KindAmulet:
return true
}
return false
}
// invisOn turns on the ability to see invisible monsters (potions.c
// invis_on).
func (g *RogueGame) invisOn() {
g.Player.Flags.Set(CanSeeInvisible)
for _, mp := range g.Level.Monsters {
if mp.On(Invisible) && g.seeMonst(mp) && !g.Player.On(Hallucinating) {
g.mvaddch(mp.Pos.Y, mp.Pos.X, mp.Disguise)
}
}
}
// turnSee puts on or off seeing monsters on this level (potions.c
// turn_see).
func (g *RogueGame) turnSee(turnOff bool) bool {
addNew := false
for _, mp := range g.Level.Monsters {
g.move(mp.Pos.Y, mp.Pos.X)
canSee := g.seeMonst(mp)
if turnOff {
if !canSee {
g.addch(mp.OldCh)
}
} else if g.showSensed(mp, canSee) {
addNew = true
}
}
if turnOff {
g.Player.Flags.Clear(SenseMonsters)
} else {
g.Player.Flags.Set(SenseMonsters)
}
return addNew
}
// showSensed draws one monster for monster sense, standout when it is
// otherwise invisible; it reports whether the monster was newly revealed
// (the turn-on arm of the C turn_see loop).
func (g *RogueGame) showSensed(mp *Monster, canSee bool) bool {
if !canSee {
g.standout()
}
if !g.Player.On(Hallucinating) {
g.addch(mp.Type)
} else {
g.addch(g.randomMonsterLetter())
}
if !canSee {
g.standend()
return true
}
return false
}
// seenStairs reports whether the player has seen the stairs (potions.c
// seen_stairs).
func (g *RogueGame) seenStairs() bool {
st := g.Level.Stairs
g.move(st.Y, st.X)
if g.inch() == Stairs { // it's on the map
return true
}
if g.Player.Pos == st { // it's under him
return true
}
// if a monster is on the stairs, this gets hairy
if tp := g.Level.MonsterAt(st.Y, st.X); tp != nil {
if g.seeMonst(tp) && tp.On(Awake) { // visible and awake:
return true // it must have moved there
}
if g.Player.On(SenseMonsters) && tp.OldCh == Stairs {
return true
}
}
return false
}

182
game/rings.go Normal file
View File

@@ -0,0 +1,182 @@
package game
import "fmt"
// rings.c — routines dealing specifically with rings.
// ringOn puts a ring on a hand (rings.c ring_on).
func (g *RogueGame) ringOn() {
p := &g.Player
obj, ok := g.promptPackItem("put on", KindRing)
// Make certain that it is something that we want to wear
if !ok {
return
}
if obj.Kind != KindRing {
g.msg("%s", g.chooseTerse("not a ring",
"it would be difficult to wrap that around a finger"))
return
}
// find out which hand to put it on
if g.isCurrent(obj) {
return
}
ring := g.pickRingHand()
if ring < 0 {
return
}
p.CurRing[ring] = obj
// Calculate the effect it has on the poor guy.
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.RingKind() {
case RingAddStrength:
g.changeStrength(obj.Bonus)
case RingSeeInvisible:
g.invisOn()
case RingAggravateMonsters:
g.aggravate()
}
if !g.Options.Terse {
g.addmsgf("you are now wearing ")
}
g.msg("%s (%c)", g.inventoryName(obj, true), obj.PackCh)
}
// pickRingHand chooses the hand for a new ring, asking when both are
// free; negative aborts (rings.c ring_on).
func (g *RogueGame) pickRingHand() int {
p := &g.Player
switch {
case p.CurRing[Left] == nil && p.CurRing[Right] == nil:
return g.gethand()
case p.CurRing[Left] == nil:
return Left
case p.CurRing[Right] == nil:
return Right
default:
g.msg("%s", g.chooseTerse("wearing two",
"you already have a ring on each hand"))
return -1
}
}
// ringOff takes off a ring (rings.c ring_off).
func (g *RogueGame) ringOff() {
p := &g.Player
var ring int
switch {
case p.CurRing[Left] == nil && p.CurRing[Right] == nil:
if g.Options.Terse {
g.msg("no rings")
} else {
g.msg("you aren't wearing any rings")
}
return
case p.CurRing[Left] == nil:
ring = Right
case p.CurRing[Right] == nil:
ring = Left
default:
if ring = g.gethand(); ring < 0 {
return
}
}
g.Msgs.Mpos = 0
obj := p.CurRing[ring]
if obj == nil {
g.msg("not wearing such a ring")
return
}
if g.dropCheck(obj) {
g.msg("was wearing %s(%c)", g.inventoryName(obj, true), obj.PackCh)
}
}
// gethand asks which hand the hero is interested in (rings.c gethand).
func (g *RogueGame) gethand() int {
for {
if g.Options.Terse {
g.msg("left or right ring? ")
} else {
g.msg("left hand or right hand? ")
}
c := g.readchar()
if c == Escape {
return -1
}
g.Msgs.Mpos = 0
if c == 'l' || c == 'L' {
return Left
}
if c == 'r' || c == 'R' {
return Right
}
if g.Options.Terse {
g.msg("L or R")
} else {
g.msg("please type L or R")
}
}
}
// ringEat reports how much food the ring on the given hand uses up
// (rings.c ring_eat).
func (g *RogueGame) ringEat(hand int) int {
ring := g.Player.CurRing[hand]
if ring == nil {
return 0
}
eat := g.data.ringUses[ring.RingKind()]
if eat < 0 {
if g.rnd(-eat) == 0 {
eat = 1
} else {
eat = 0
}
}
if ring.RingKind() == RingSlowDigestion {
eat = -eat
}
return eat
}
// ringNum prints ring bonuses (rings.c ring_num). The unused game
// parameter keeps the nameit prfunc signature.
func ringNum(_ *RogueGame, obj *Object) string {
if !obj.Flags.Has(Known) {
return ""
}
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.RingKind() {
case RingProtection, RingAddStrength, RingIncreaseDamage, RingDexterity:
return fmt.Sprintf(" [%s]", num(obj.Bonus, 0, Ring))
}
return ""
}

751
game/rings_test.go Normal file
View File

@@ -0,0 +1,751 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"fmt"
"math"
"testing"
)
// rings_test.go covers rings.c — ring_on, gethand, ring_off, ring_eat and
// ring_num — plus the ring arm of things.c dropcheck (dropRing), which is
// what actually takes a worn ring off.
//
// Every expected value below is transcribed from the C reference on the
// origin/c-master branch (rings.c, rogue.h, things.c), not from what this
// port happens to return. A test that asserts the current Go behaviour
// cannot catch the port drifting away from C, which is the only thing
// these tests exist to do.
//
// The C constants in play (rogue.h 122-123 and 275-289):
//
// #define LEFT 0 #define RIGHT 1
// R_PROTECT 0 R_ADDSTR 1 R_SUSTSTR 2 R_SEARCH 3
// R_SEEINVIS 4 R_NOP 5 R_AGGR 6 R_ADDHIT 7
// R_ADDDAM 8 R_REGEN 9 R_DIGEST 10 R_TELEPORT 11
// R_STEALTH 12 R_SUSTARM 13 MAXRINGS 14
//
// The Go RingKind iota (types.go 303-316) runs in that same order, so a C
// uses[] index and a Go RingKind are the same number. R_ADDHIT is the
// dexterity ring (RingDexterity) and R_ADDDAM is RingIncreaseDamage.
//
// Nothing here can kill the hero — no ring path in rings.c touches HP,
// food, or experience — so these tests need no fortify() pinning.
// C message text, verbatim, in the terse/verbose pairs C picks between.
const (
cWearingTwo = "you already have a ring on each hand"
cWearingTwoTerse = "wearing two"
cNotARing = "it would be difficult to wrap that around a finger"
cNotARingTerse = "not a ring"
cNoRings = "you aren't wearing any rings"
cNoRingsTerse = "no rings"
cInUse = "That's already in use"
cCursed = "you can't. It appears to be cursed"
)
// ringSeed is the fixed seed every test here runs on: nothing in rings.c
// depends on the layout, but the RNG stream must be reproducible for the
// ring_eat chance rolls.
const ringSeed = 5
// mkRingGame builds a headless game with a clear message line, so that a
// leftover mpos cannot turn the next msg() into a --More-- that eats the
// scripted keystrokes.
func mkRingGame(t *testing.T) *RogueGame {
t.Helper()
g := mkGame(t, ringSeed)
g.Msgs.Mpos = 0
g.Msgs.Huh = ""
return g
}
// handKeys scripts an answer to gethand and appends an abort tail. Without
// it, a port that stopped accepting the key under test would reprompt
// forever against the headless terminal's filler input, and the test would
// die of the 30s timeout instead of failing on its own assertion. The
// space acknowledges the reprompt's --More-- and the ESCAPE makes gethand
// give up, so the assertion gets to run and say what actually went wrong.
// For the same reason, a call that must not prompt at all is scripted with
// a lone ESCAPE rather than an empty script.
func handKeys(keys ...byte) []byte {
return append(keys, ' ', Escape)
}
// mkRing builds a ring of the given kind; bonus is C's o_arm.
func mkRing(kind RingKind, bonus int) *Object {
obj := newObject()
obj.Kind = KindRing
obj.Which = int(kind)
obj.Bonus = bonus
obj.Count = 1
return obj
}
// wear puts a ring straight onto a hand the way a restored save would,
// bypassing ring_on's prompting and effects.
func wear(g *RogueGame, hand int, obj *Object) *Object {
give(g, obj)
g.Player.CurRing[hand] = obj
return obj
}
func handDesc(obj *Object) string {
if obj == nil {
return "empty"
}
return fmt.Sprintf("ring kind %d", obj.RingKind())
}
// assertHands pins both hands at once, which is what "no state change"
// means for every rejection path in ring_on.
func assertHands(t *testing.T, g *RogueGame, left, right *Object) {
t.Helper()
if g.Player.CurRing[Left] != left {
t.Errorf("left hand = %s, want %s",
handDesc(g.Player.CurRing[Left]), handDesc(left))
}
if g.Player.CurRing[Right] != right {
t.Errorf("right hand = %s, want %s",
handDesc(g.Player.CurRing[Right]), handDesc(right))
}
}
// TestRingOnUsesTheHandTheHeroPicks covers the first arm of C's ring_on
// hand choice: "if (cur_ring[LEFT] == NULL && cur_ring[RIGHT] == NULL)
// { if ((ring = gethand()) < 0) return; }".
func TestRingOnUsesTheHandTheHeroPicks(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
key byte
hand int
}{
{"lower l", 'l', Left},
{"upper L", 'L', Left},
{"lower r", 'r', Right},
{"upper R", 'R', Right},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
ring := mkRing(RingAdornment, 0)
ch := give(g, ring)
setInput(t, g, handKeys(ch, tc.key)...)
g.ringOn()
if tc.hand == Left {
assertHands(t, g, ring, nil)
} else {
assertHands(t, g, nil, ring)
}
})
}
}
// TestRingOnEscapeFromGethandWearsNothing is the "< 0" half of that arm.
func TestRingOnEscapeFromGethandWearsNothing(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
ring := mkRing(RingAdornment, 0)
ch := give(g, ring)
setInput(t, g, ch, Escape)
g.ringOn()
assertHands(t, g, nil, nil)
}
// TestRingOnTakesTheOnlyFreeHandWithoutAsking covers C's second and third
// arms — "else if (cur_ring[LEFT] == NULL) ring = LEFT" and the RIGHT
// mirror — which must not prompt. The scripted hand key is deliberately
// the wrong hand: a port that asked anyway would consume it and put the
// ring on the occupied side's opposite, failing here instead of hanging.
func TestRingOnTakesTheOnlyFreeHandWithoutAsking(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
worn int
free int
badKey byte
}{
{"left already worn", Left, Right, 'l'},
{"right already worn", Right, Left, 'r'},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
old := wear(g, tc.worn, mkRing(RingStealth, 0))
ring := mkRing(RingAdornment, 0)
ch := give(g, ring)
setInput(t, g, handKeys(ch, tc.badKey)...)
g.ringOn()
if g.Player.CurRing[tc.free] != ring {
t.Errorf("free hand = %s, want the new ring",
handDesc(g.Player.CurRing[tc.free]))
}
if g.Player.CurRing[tc.worn] != old {
t.Error("ring_on disturbed the hand that was already worn")
}
})
}
}
// TestRingOnWithBothHandsFullIsRejected covers C's final else arm. The
// trailing ESCAPE is scripted so that a port which wrongly fell through
// to gethand() aborts instead of looping on the exhausted script.
func TestRingOnWithBothHandsFullIsRejected(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
terse bool
want string
}{
{"wearing two, verbose", false, cWearingTwo},
{"wearing two, terse", true, cWearingTwoTerse},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
g.Options.Terse = tc.terse
left := wear(g, Left, mkRing(RingStealth, 0))
right := wear(g, Right, mkRing(RingRegeneration, 0))
ch := give(g, mkRing(RingAdornment, 0))
setInput(t, g, ch, Escape)
g.ringOn()
if g.Msgs.Huh != tc.want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.want)
}
assertHands(t, g, left, right)
})
}
}
// TestRingOnRejectsANonRing covers C's "if (obj->o_type != RING)" guard.
func TestRingOnRejectsANonRing(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
terse bool
want string
}{
{"not a ring, verbose", false, cNotARing},
{"not a ring, terse", true, cNotARingTerse},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
g.Options.Terse = tc.terse
pot := newObject()
pot.Kind = KindPotion
pot.Which = int(PotionHealing)
ch := give(g, pot)
setInput(t, g, ch, Escape)
g.ringOn()
if g.Msgs.Huh != tc.want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.want)
}
assertHands(t, g, nil, nil)
})
}
}
// TestRingOnRejectsARingAlreadyWorn covers C's "if (is_current(obj))
// return", which sits between the type check and the hand choice.
func TestRingOnRejectsARingAlreadyWorn(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
worn := wear(g, Left, mkRing(RingStealth, 0))
setInput(t, g, worn.PackCh, Escape)
g.ringOn()
if g.Msgs.Huh != cInUse {
t.Errorf("message = %q, want %q", g.Msgs.Huh, cInUse)
}
assertHands(t, g, worn, nil)
}
// TestRingOnAddStrengthAndRingOffReverseEachOther pins the R_ADDSTR arm
// of ring_on ("case R_ADDSTR: chg_str(obj->o_arm)") against the R_ADDSTR
// arm of things.c dropcheck ("chg_str(-obj->o_arm)").
func TestRingOnAddStrengthAndRingOffReverseEachOther(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
ring := mkRing(RingAddStrength, 2)
ch := give(g, ring)
base := g.Player.Stats.Str
setInput(t, g, handKeys(ch, 'l')...)
g.ringOn()
if g.Player.Stats.Str != base+2 {
t.Errorf("strength after wearing = %d, want %d",
g.Player.Stats.Str, base+2)
}
assertHands(t, g, ring, nil)
// Only the left hand is worn, so ring_off's "else if (cur_ring[RIGHT]
// == NULL) ring = LEFT" arm picks it with no prompt.
setInput(t, g, Escape)
g.ringOff()
if g.Player.Stats.Str != base {
t.Errorf("strength after removal = %d, want %d",
g.Player.Stats.Str, base)
}
assertHands(t, g, nil, nil)
}
// TestRingOnSeeInvisibleAndRingOffUndoIt pins the R_SEEINVIS arms:
// invis_on() on the way in, unsee() plus extinguish(unsee) on the way
// out. The pending fuse stands in for a potion of see invisible still
// running, which is the only way the extinguish is observable.
func TestRingOnSeeInvisibleAndRingOffUndoIt(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
ring := mkRing(RingSeeInvisible, 0)
ch := give(g, ring)
setInput(t, g, handKeys(ch, 'r')...)
g.ringOn()
if !g.Player.On(CanSeeInvisible) {
t.Error("ring of see invisible did not set CanSeeInvisible")
}
g.Fuse(DUnsee, 0, 100, After)
setInput(t, g, Escape)
g.ringOff()
if g.Player.On(CanSeeInvisible) {
t.Error("taking the ring off left CanSeeInvisible set")
}
if g.findSlot(DUnsee) != nil {
t.Error("taking the ring off did not extinguish the unsee fuse")
}
}
// TestRingOnAggravateMonstersWakesThem pins the R_AGGR arm, which calls
// aggravate() — misc.c walks every monster through runTo, setting ISRUN.
func TestRingOnAggravateMonstersWakesThem(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
tp := spawnAdjacent(g, 'Z')
tp.Flags.Clear(Awake)
ring := mkRing(RingAggravateMonsters, 0)
ch := give(g, ring)
setInput(t, g, handKeys(ch, 'l')...)
g.ringOn()
if !tp.On(Awake) {
t.Error("ring of aggravate monsters did not wake the monster")
}
}
// TestGethand covers rings.c gethand end to end. The bad-key case needs
// the extra space: the reprompt happens with mpos still set from "please
// type L or R", so endmsg puts up a --More-- that wait_for absorbs.
func TestGethand(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
input []byte
want int
}{
{"l", []byte{'l'}, Left},
{"L", []byte{'L'}, Left},
{"r", []byte{'r'}, Right},
{"R", []byte{'R'}, Right},
{"escape aborts", []byte{Escape}, -1},
{"bad key reprompts", []byte{'x', ' ', 'r'}, Right},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
setInput(t, g, handKeys(tc.input...)...)
if got := g.gethand(); got != tc.want {
t.Errorf("gethand() = %d, want %d", got, tc.want)
}
})
}
}
// TestRingOffWithNoRingsSaysSo covers ring_off's first arm.
func TestRingOffWithNoRingsSaysSo(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
terse bool
want string
}{
{"no rings, verbose", false, cNoRings},
{"no rings, terse", true, cNoRingsTerse},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
g.Options.Terse = tc.terse
setInput(t, g, Escape)
g.ringOff()
if g.Msgs.Huh != tc.want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.want)
}
})
}
}
// TestRingOffWithBothHandsWornAsksWhich covers ring_off's else arm, both
// the answer and the "(ring = gethand()) < 0" abort.
func TestRingOffWithBothHandsWornAsksWhich(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
key byte
gone int
stays int
}{
{"takes off the left", 'l', Left, Right},
{"takes off the right", 'r', Right, Left},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
rings := [2]*Object{
Left: wear(g, Left, mkRing(RingStealth, 0)),
Right: wear(g, Right, mkRing(RingRegeneration, 0)),
}
setInput(t, g, handKeys(tc.key)...)
g.ringOff()
if g.Player.CurRing[tc.gone] != nil {
t.Errorf("chosen hand still holds %s",
handDesc(g.Player.CurRing[tc.gone]))
}
if g.Player.CurRing[tc.stays] != rings[tc.stays] {
t.Error("ring_off cleared the hand that was not chosen")
}
})
}
}
// TestRingOffEscapeKeepsBothRings is the abort half of that arm.
func TestRingOffEscapeKeepsBothRings(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
left := wear(g, Left, mkRing(RingStealth, 0))
right := wear(g, Right, mkRing(RingRegeneration, 0))
setInput(t, g, Escape)
g.ringOff()
assertHands(t, g, left, right)
}
// TestRingOffCursedRingStaysOn covers the dropcheck gate ring_off runs
// its removal through: things.c returns FALSE for an ISCURSED item after
// printing this message, and the hand is left alone.
func TestRingOffCursedRingStaysOn(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
ring := mkRing(RingAddStrength, -1)
ring.Flags.Set(Cursed)
wear(g, Left, ring)
setInput(t, g, Escape)
g.ringOff()
if g.Msgs.Huh != cCursed {
t.Errorf("message = %q, want %q", g.Msgs.Huh, cCursed)
}
assertHands(t, g, ring, nil)
}
// cRingUse is one entry of the rings.c ring_eat uses[] table.
type cRingUse struct {
kind RingKind
name string // the C R_ name, for failure messages
uses int
}
// cRingUses transcribes ring_eat's static uses[] verbatim:
//
// static int uses[] = {
// 1, /* R_PROTECT */ 1, /* R_ADDSTR */
// 1, /* R_SUSTSTR */ -3, /* R_SEARCH */
// -5, /* R_SEEINVIS */ 0, /* R_NOP */
// 0, /* R_AGGR */ -3, /* R_ADDHIT */
// -3, /* R_ADDDAM */ 2, /* R_REGEN */
// -2, /* R_DIGEST */ 0, /* R_TELEPORT */
// 1, /* R_STEALTH */ 1 /* R_SUSTARM */
// };
//
// A negative entry is not a cost: C computes eat = (rnd(-eat) == 0), a
// one-in-n chance of a single unit. R_DIGEST then flips the sign, so slow
// digestion returns 0 or -1 and is the only ring that gives food back.
func cRingUses() []cRingUse {
return []cRingUse{
{RingProtection, "R_PROTECT", 1},
{RingAddStrength, "R_ADDSTR", 1},
{RingSustainStrength, "R_SUSTSTR", 1},
{RingSearching, "R_SEARCH", -3},
{RingSeeInvisible, "R_SEEINVIS", -5},
{RingAdornment, "R_NOP", 0},
{RingAggravateMonsters, "R_AGGR", 0},
{RingDexterity, "R_ADDHIT", -3},
{RingIncreaseDamage, "R_ADDDAM", -3},
{RingRegeneration, "R_REGEN", 2},
{RingSlowDigestion, "R_DIGEST", -2},
{RingTeleportation, "R_TELEPORT", 0},
{RingStealth, "R_STEALTH", 1},
{RingMaintainArmor, "R_SUSTARM", 1},
}
}
// TestRingEatMatchesTheCUsesTable exercises all fourteen ring kinds, both
// hands, against the C table above. This is the highest-value assertion in
// the file: ring_eat feeds the hunger clock through daemons.c, so a wrong
// entry is a silent, slow divergence from C that no other test would see.
func TestRingEatMatchesTheCUsesTable(t *testing.T) {
t.Parallel()
for _, tc := range cRingUses() {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
for _, hand := range []int{Left, Right} {
g := mkRingGame(t)
g.Player.CurRing[hand] = mkRing(tc.kind, 0)
if tc.uses >= 0 {
assertFixedRingEat(t, g, hand, tc)
} else {
assertChanceRingEat(t, g, hand, tc)
}
}
})
}
}
// assertFixedRingEat checks a non-negative uses[] entry. C returns it
// unchanged and, just as importantly, never reaches rnd() on that path —
// so the generator must be untouched, or the whole game's RNG stream
// desynchronises from C's and seed compatibility is gone.
func assertFixedRingEat(t *testing.T, g *RogueGame, hand int, tc cRingUse) {
t.Helper()
for range 4 {
before := *g.Rng
if got := g.ringEat(hand); got != tc.uses {
t.Fatalf("ringEat(%d) for %s = %d, want C uses[] entry %d",
hand, tc.name, got, tc.uses)
}
if *g.Rng != before {
t.Fatalf("ringEat for %s called rnd(); C only does that for a "+
"negative uses[] entry", tc.name)
}
}
}
// assertChanceRingEat checks a negative uses[] entry. Each call is replayed
// against C's own expression from the identical generator state, which pins
// the one-in-n denominator, the sign flip R_DIGEST gets, and the fact that
// exactly one rnd() call is spent. The frequency check on top of that
// fails loudly on a wrong denominator even if the replay were ever
// weakened to agree with the code by construction.
func assertChanceRingEat(t *testing.T, g *RogueGame, hand int, tc cRingUse) {
t.Helper()
const trials = 4000
sign := 1
if tc.kind == RingSlowDigestion {
sign = -1 // rings.c: if (ring->o_which == R_DIGEST) eat = -eat
}
nonzero := 0
for range trials {
before := *g.Rng
got := g.ringEat(hand)
after := *g.Rng
// C: eat = (rnd(-eat) == 0), replayed from the same state.
*g.Rng = before
want := 0
if g.Rng.Rnd(-tc.uses) == 0 {
want = 1
}
want *= sign
if *g.Rng != after {
t.Fatalf("ringEat for %s did not spend exactly one rnd(%d) call",
tc.name, -tc.uses)
}
if got != want {
t.Fatalf("ringEat for %s = %d, want %d", tc.name, got, want)
}
if want != 0 {
nonzero++
}
}
assertOneInN(t, tc, nonzero, trials)
}
// assertOneInN checks the observed rate against C's 1/n. The tolerance is
// far tighter than the gap between the three denominators C uses (1/2,
// 1/3, 1/5) and far wider than the sampling noise at this trial count.
func assertOneInN(t *testing.T, tc cRingUse, nonzero, trials int) {
t.Helper()
const tolerance = 0.03
rate := float64(nonzero) / float64(trials)
want := 1 / float64(-tc.uses)
if math.Abs(rate-want) > tolerance {
t.Errorf("%s fired %.3f of the time over %d trials, want ~%.3f "+
"(C's one-in-%d)", tc.name, rate, trials, want, -tc.uses)
}
}
// TestRingEatEmptyHandIsZero is C's "if ((ring = cur_ring[hand]) == NULL)
// return 0" — the common case, since the hero usually wears nothing.
func TestRingEatEmptyHandIsZero(t *testing.T) {
t.Parallel()
g := mkRingGame(t)
before := *g.Rng
for _, hand := range []int{Left, Right} {
if got := g.ringEat(hand); got != 0 {
t.Errorf("ringEat(%d) with an empty hand = %d, want 0", hand, got)
}
}
if *g.Rng != before {
t.Error("ringEat on an empty hand consumed RNG")
}
}
// TestRingNum covers rings.c ring_num. Its switch ends in the `otherwise`
// macro, which rogue.h 53 defines as `break;default` — so the four labels
// R_PROTECT, R_ADDSTR, R_ADDDAM and R_ADDHIT fall through to a single
// sprintf(" [%s]", num(o_arm, 0, RING)) and every other kind returns ""
// from the default arm before the buffer is ever reached. Unknown rings
// return "" earlier still, from the ISKNOW guard.
//
// The game pointer is C's implicit global state; ring_num reads none of
// it, and the port's signature only carries one to satisfy nameit's
// prfunc type, so nil is the honest argument here.
func TestRingNum(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
kind RingKind
bonus int
known bool
want string
}{
{"R_PROTECT known", RingProtection, 2, true, " [+2]"},
{"R_ADDSTR known", RingAddStrength, 1, true, " [+1]"},
{"R_ADDDAM known", RingIncreaseDamage, -1, true, " [-1]"},
{"R_ADDHIT known", RingDexterity, 3, true, " [+3]"},
{"R_PROTECT cursed", RingProtection, -1, true, " [-1]"},
{"R_ADDSTR unknown", RingAddStrength, 2, false, ""},
{"R_SEARCH known", RingSearching, 2, true, ""},
{"R_DIGEST known", RingSlowDigestion, 2, true, ""},
{"R_NOP known", RingAdornment, 0, true, ""},
{"R_SUSTARM known", RingMaintainArmor, 2, true, ""},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
obj := mkRing(tc.kind, tc.bonus)
if tc.known {
obj.Flags.Set(Known)
}
if got := ringNum(nil, obj); got != tc.want {
t.Errorf("ringNum() = %q, want %q", got, tc.want)
}
})
}
}
// Coverage of the fourteen ring kinds, for the record:
//
// All fourteen are exercised by TestRingEatMatchesTheCUsesTable and ten of
// them by TestRingNum. Beyond that, only three kinds have a ring_on effect
// at all — R_ADDSTR, R_SEEINVIS and R_AGGR — and each has its own test
// above, paired with the dropcheck arm that undoes it. The remaining
// eleven are deliberately not given a wear/remove test: in C they are
// inert at wear time, their powers being read from ISWEARING() elsewhere
// (R_SEARCH and R_TELEPORT in the command.c per-turn tail, R_PROTECT and
// R_ADDHIT/R_ADDDAM in fight.c, R_REGEN and R_DIGEST in daemons.c,
// R_SUSTSTR and R_SUSTARM in the drain paths, R_STEALTH in chase.c), so a
// wear/remove assertion for them would test nothing that rings.c does.
// Those call sites belong to their own files' tests, not to this one.
//
// One branch is intentionally unreachable rather than untested: ring_off's
// "obj == NULL -> not wearing such a ring" cannot fire, because every arm
// that reaches it has already established that the chosen hand is worn.
// The port keeps C's defensive check; there is no state from which to
// provoke it.

290
game/rip.go Normal file
View File

@@ -0,0 +1,290 @@
//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))]
}

56
game/rng.go Normal file
View File

@@ -0,0 +1,56 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// Rng is the original Rogue linear congruential generator. The C RN macro is
//
// #define RN (((seed = seed*11109+13849) >> 16) & 0xffff)
//
// with `seed` a C int; Seed is int32 so the multiplication wraps exactly the
// way 32-bit C arithmetic does. A given seed therefore produces the same
// dungeon as the C game.
type Rng struct {
Seed int32
}
// Rnd picks a very random number in [0, rng) (main.c rnd).
func (r *Rng) Rnd(rng int) int {
if rng == 0 {
return 0
}
v := r.next()
if v < 0 {
v = -v
}
return v % rng
}
// Roll rolls a number of dice (main.c roll).
func (r *Rng) Roll(number, sides int) int {
dtotal := 0
for ; number > 0; number-- {
dtotal += r.Rnd(sides) + 1
}
return dtotal
}
// next steps the generator and returns the next raw value (the RN macro).
func (r *Rng) next() int {
r.Seed = r.Seed*11109 + 13849
return int(r.Seed>>16) & 0xffff
}
// rnd is the ported code's spelling of C rnd(): every call site in the C
// sources reads rnd(x), and keeping that shape makes cross-checking easy.
func (g *RogueGame) rnd(rng int) int { return g.Rng.Rnd(rng) }
// roll is C roll().
func (g *RogueGame) roll(number, sides int) int { return g.Rng.Roll(number, sides) }
// spread gives a fuzzy number centered on nm (misc.c spread).
func (g *RogueGame) spread(nm int) int {
return nm - nm/20 + g.rnd(nm/10)
}

75
game/rng_test.go Normal file
View File

@@ -0,0 +1,75 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import "testing"
// The golden values below were produced by the original C generator
// (seed = seed*11109+13849; rnd(range) = abs(RN) % range) compiled and run
// on this machine. They lock in seed compatibility with the C game.
func TestRndMatchesCImplementation(t *testing.T) {
t.Parallel()
golden := map[int32][]int{
1: {0, 30, 79, 7, 87, 1, 23, 7, 57, 98},
12345: {92, 92, 45, 98, 24, 39, 92, 67, 3, 7},
2026: {43, 90, 6, 2, 43, 34, 20, 5, 67, 71},
1751808000: {61, 51, 56, 99, 68, 45, 88, 50, 46, 88},
}
for seed, want := range golden {
r := &Rng{Seed: seed}
for i, w := range want {
if got := r.Rnd(100); got != w {
t.Errorf("seed %d: rnd(100) call %d = %d, want %d", seed, i, got, w)
}
}
}
}
func TestRollMatchesCImplementation(t *testing.T) {
t.Parallel()
golden := map[int32][]int{
1: {6, 8, 10},
12345: {10, 10, 15},
2026: {10, 10, 13},
1751808000: {7, 9, 9},
}
for seed, want := range golden {
r := &Rng{Seed: seed}
for i, w := range want {
if got := r.Roll(3, 6); got != w {
t.Errorf("seed %d: roll(3,6) call %d = %d, want %d", seed, i, got, w)
}
}
}
}
// rnd(0) must return 0 without stepping the generator: the C macro
// short-circuits before evaluating RN, and BEFORE/AFTER depend on it.
func TestRndZeroDoesNotStep(t *testing.T) {
t.Parallel()
r := &Rng{Seed: 42}
if got := r.Rnd(0); got != 0 {
t.Fatalf("rnd(0) = %d, want 0", got)
}
if r.Seed != 42 {
t.Fatalf("rnd(0) stepped the generator: seed = %d, want 42", r.Seed)
}
}
// spread(1)==1 and spread(2)==2 deterministically; the C BEFORE/AFTER
// constants rely on this.
func TestSpreadSmallValues(t *testing.T) {
t.Parallel()
g := &RogueGame{Rng: &Rng{Seed: 7}}
if got := g.spread(1); got != 1 {
t.Errorf("spread(1) = %d, want 1", got)
}
if got := g.spread(2); got != 2 {
t.Errorf("spread(2) = %d, want 2", got)
}
}

481
game/rooms.go Normal file
View File

@@ -0,0 +1,481 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// rooms.c — create the layout for the new level.
// spot is the rooms.c SPOT position matrix for maze positions.
type spot struct {
nexits int
exits [4]Coord
used bool
}
// mazeState is the rooms.c file-scope maze generation state.
type mazeState struct {
maxy, maxx, starty, startx int
maze [NumLines/3 + 1][NumCols/3 + 1]spot
}
const goldGrp = 1
// digRooms creates rooms and corridors with a connectivity graph (rooms.c
// do_rooms).
func (g *RogueGame) digRooms() {
var bsze Coord // maximum room size
bsze.X = NumCols / 3
bsze.Y = NumLines / 3
// Clear things for a new level
for i := range g.Level.Rooms {
rp := &g.Level.Rooms[i]
rp.GoldVal = 0
rp.Exits = rp.Exits[:0]
rp.Flags = 0
}
// Put the gone rooms, if any, on the level
leftOut := g.rnd(4)
for range leftOut {
g.Level.Rooms[g.randomRoom()].Flags.Set(Gone)
}
// dig and populate all the rooms on the level
for i := range g.Level.Rooms {
g.digRoom(i, bsze)
}
}
// digRoom digs and populates one room (the loop body of rooms.c
// do_rooms).
func (g *RogueGame) digRoom(i int, bsze Coord) {
rp := &g.Level.Rooms[i]
// Find upper left corner of box that this room goes in
top := Coord{X: (i%3)*bsze.X + 1, Y: (i / 3) * bsze.Y}
if rp.Flags.Has(Gone) {
g.placeGoneRoom(rp, top, bsze)
return
}
// set room type
if g.rnd(10) < g.Depth-1 {
rp.Flags.Set(Dark) // dark room
if g.rnd(15) == 0 {
rp.Flags = Maze // maze room
}
}
// Find a place and size for a random room
if rp.Flags.Has(Maze) {
placeMazeRoom(rp, top, bsze)
} else {
g.placeNormalRoom(rp, top, bsze)
}
g.drawRoom(rp)
g.roomGold(rp)
g.roomMonster(rp)
}
// placeGoneRoom places a gone room, making certain that there is a
// blank line for passage drawing (rooms.c do_rooms).
func (g *RogueGame) placeGoneRoom(rp *Room, top, bsze Coord) {
for {
rp.Pos.X = top.X + g.rnd(bsze.X-2) + 1
rp.Pos.Y = top.Y + g.rnd(bsze.Y-2) + 1
rp.Max.X = -NumCols
rp.Max.Y = -NumLines
if rp.Pos.Y > 0 && rp.Pos.Y < NumLines-1 {
return
}
}
}
// placeMazeRoom sizes a maze room to fill its box (rooms.c do_rooms).
func placeMazeRoom(rp *Room, top, bsze Coord) {
rp.Max.X = bsze.X - 1
rp.Max.Y = bsze.Y - 1
if rp.Pos.X = top.X; rp.Pos.X == 1 {
rp.Pos.X = 0
}
if rp.Pos.Y = top.Y; rp.Pos.Y == 0 {
rp.Pos.Y++
rp.Max.Y--
}
}
// placeNormalRoom rolls a place and size for an ordinary room (rooms.c
// do_rooms).
func (g *RogueGame) placeNormalRoom(rp *Room, top, bsze Coord) {
for {
rp.Max.X = g.rnd(bsze.X-4) + 4
rp.Max.Y = g.rnd(bsze.Y-4) + 4
rp.Pos.X = top.X + g.rnd(bsze.X-rp.Max.X)
rp.Pos.Y = top.Y + g.rnd(bsze.Y-rp.Max.Y)
if rp.Pos.Y != 0 {
return
}
}
}
// roomGold maybe puts a gold pile in the room (rooms.c do_rooms).
func (g *RogueGame) roomGold(rp *Room) {
if g.rnd(2) != 0 || (g.HasAmulet && g.Depth < g.MaxDepth) {
return
}
gold := newObject()
rp.GoldVal = g.goldCalc()
gold.GoldValue = rp.GoldVal
rp.Gold, _ = g.findFloorIn(rp, 0, false)
gold.Pos = rp.Gold
g.Level.SetChar(rp.Gold.Y, rp.Gold.X, Gold)
gold.Flags = Stackable
gold.Group = goldGrp
gold.Kind = KindGold
g.Level.AddObject(gold)
}
// roomMonster maybe puts a monster in the room; gold attracts them
// (rooms.c do_rooms).
func (g *RogueGame) roomMonster(rp *Room) {
prob := 25
if rp.GoldVal > 0 {
prob = 80
}
if g.rnd(100) < prob {
tp := &Monster{}
mp, _ := g.findFloorIn(rp, 0, true)
g.newMonster(tp, g.randMonster(false), mp)
g.givePack(tp)
}
}
// drawRoom draws a box around a room and lays down the floor for normal
// rooms; for maze rooms, draws the maze (rooms.c draw_room).
func (g *RogueGame) drawRoom(rp *Room) {
if rp.Flags.Has(Maze) {
g.digMaze(rp)
return
}
g.vert(rp, rp.Pos.X) // Draw left side
g.vert(rp, rp.Pos.X+rp.Max.X-1) // Draw right side
g.horiz(rp, rp.Pos.Y) // Draw top
g.horiz(rp, rp.Pos.Y+rp.Max.Y-1) // Draw bottom
// Put the floor down
for y := rp.Pos.Y + 1; y < rp.Pos.Y+rp.Max.Y-1; y++ {
for x := rp.Pos.X + 1; x < rp.Pos.X+rp.Max.X-1; x++ {
g.Level.SetChar(y, x, Floor)
}
}
}
// vert draws a vertical line (rooms.c vert).
func (g *RogueGame) vert(rp *Room, startx int) {
for y := rp.Pos.Y + 1; y <= rp.Max.Y+rp.Pos.Y-1; y++ {
g.Level.SetChar(y, startx, '|')
}
}
// horiz draws a horizontal line (rooms.c horiz).
func (g *RogueGame) horiz(rp *Room, starty int) {
for x := rp.Pos.X; x <= rp.Pos.X+rp.Max.X-1; x++ {
g.Level.SetChar(starty, x, '-')
}
}
// digMaze digs a maze (rooms.c do_maze).
func (g *RogueGame) digMaze(rp *Room) {
m := &g.maze
for y := range m.maze {
for x := range m.maze[y] {
m.maze[y][x].used = false
m.maze[y][x].nexits = 0
}
}
m.maxy = rp.Max.Y
m.maxx = rp.Max.X
m.starty = rp.Pos.Y
m.startx = rp.Pos.X
starty := (g.rnd(rp.Max.Y) / 2) * 2
startx := (g.rnd(rp.Max.X) / 2) * 2
pos := Coord{Y: starty + m.starty, X: startx + m.startx}
g.putPassage(pos)
g.dig(starty, startx)
}
// dig digs out from around where we are now, if possible (rooms.c dig).
func (g *RogueGame) dig(y, x int) {
m := &g.maze
for {
nexty, nextx, ok := g.digPick(y, x)
if !ok {
return
}
g.accountMaze(y, x, nexty, nextx)
g.accountMaze(nexty, nextx, y, x)
g.putPassage(digWallGap(m, y, x, nexty, nextx))
g.putPassage(Coord{Y: nexty + m.starty, X: nextx + m.startx})
g.dig(nexty, nextx)
}
}
// digPick reservoir-picks the next unvisited maze cell; ok is false
// when the digger is boxed in (the candidate scan of rooms.c dig).
func (g *RogueGame) digPick(y, x int) (int, int, bool) {
m := &g.maze
del := [4]Coord{{X: 2, Y: 0}, {X: -2, Y: 0}, {X: 0, Y: 2}, {X: 0, Y: -2}}
cnt := 0
var nexty, nextx int
for _, cp := range del {
newy := y + cp.Y
newx := x + cp.X
if newy < 0 || newy > m.maxy || newx < 0 || newx > m.maxx {
continue
}
if g.Level.FlagsAt(newy+m.starty, newx+m.startx).Has(FPassage) {
continue
}
if cnt++; g.rnd(cnt) == 0 {
nexty = newy
nextx = newx
}
}
return nexty, nextx, cnt != 0
}
// digWallGap picks the wall square to knock out between two maze cells
// (rooms.c dig).
func digWallGap(m *mazeState, y, x, nexty, nextx int) Coord {
if nexty == y {
pos := Coord{Y: y + m.starty, X: nextx + m.startx - 1}
if nextx-x < 0 {
pos.X = nextx + m.startx + 1
}
return pos
}
pos := Coord{X: x + m.startx, Y: nexty + m.starty - 1}
if nexty-y < 0 {
pos.Y = nexty + m.starty + 1
}
return pos
}
// accountMaze accounts for maze exits (rooms.c accnt_maze).
func (g *RogueGame) accountMaze(y, x, ny, nx int) {
sp := &g.maze.maze[y][x]
for i := range sp.nexits {
if sp.exits[i].Y == ny && sp.exits[i].X == nx {
return
}
}
// Note: the C original never increments nexits here (a latent bug it
// inherits); it stores the exit in the next slot and relies on the
// slot staying zero-counted. We reproduce the store-without-count.
if sp.nexits < len(sp.exits) {
sp.exits[sp.nexits] = Coord{Y: ny, X: nx}
}
}
// randomPos picks a random spot in a room (rooms.c rnd_pos).
func (g *RogueGame) randomPos(rp *Room) Coord {
var cp Coord
cp.X = rp.Pos.X + g.rnd(rp.Max.X-2) + 1
cp.Y = rp.Pos.Y + g.rnd(rp.Max.Y-2) + 1
return cp
}
// findFloor finds a valid floor spot, picking a new random room each time
// around the loop; it retries forever (rooms.c find_floor with rp == NULL
// — every such C call site passed FALSE for the limit).
func (g *RogueGame) findFloor(monst bool) (Coord, bool) {
return g.findFloorImpl(nil, 0, monst, true)
}
// findFloorIn finds a valid floor spot in this room (rooms.c find_floor
// with a specific room).
func (g *RogueGame) findFloorIn(rp *Room, limit int, monst bool) (Coord, bool) {
return g.findFloorImpl(rp, limit, monst, false)
}
// floorChar is what an empty spot looks like in a room: passage in a
// maze, floor otherwise (rooms.c find_floor).
func floorChar(rp *Room) byte {
if rp.Flags.Has(Maze) {
return Passage
}
return Floor
}
func (g *RogueGame) findFloorImpl(
rp *Room, limit int, monst, pickroom bool,
) (Coord, bool) {
var compchar byte
if !pickroom {
compchar = floorChar(rp)
}
cnt := limit
for {
if limit != 0 {
if cnt--; cnt == -1 {
return Coord{}, false
}
}
if pickroom {
rp = &g.Level.Rooms[g.randomRoom()]
compchar = floorChar(rp)
}
cp := g.randomPos(rp)
pp := g.Level.At(cp.Y, cp.X)
if monst {
if pp.Monst == nil && stepOk(pp.Ch) {
return cp, true
}
} else if pp.Ch == compchar {
return cp, true
}
}
}
// enterRoom is the code executed whenever you appear in a room (rooms.c
// enter_room).
func (g *RogueGame) enterRoom(cp Coord) {
p := &g.Player
rp := g.roomIn(cp)
p.Room = rp
g.doorOpen(rp)
if !rp.Flags.Has(Dark) && !p.On(Blind) {
for y := rp.Pos.Y; y < rp.Max.Y+rp.Pos.Y; y++ {
g.move(y, rp.Pos.X)
for x := rp.Pos.X; x < rp.Max.X+rp.Pos.X; x++ {
g.enterRoomCell(y, x)
}
}
}
}
// enterRoomCell draws one square of a room being lit on entry (the loop
// body of rooms.c enter_room).
func (g *RogueGame) enterRoomCell(y, x int) {
tp := g.Level.MonsterAt(y, x)
ch := g.Level.Char(y, x)
if tp == nil {
if g.inch() != ch {
g.addch(ch)
} else {
g.move(y, x+1)
}
return
}
tp.OldCh = ch
if g.seeMonst(tp) {
g.addch(tp.Disguise)
return
}
if g.Player.On(SenseMonsters) {
g.standout()
g.addch(tp.Disguise)
g.standend()
} else {
g.addch(ch)
}
}
// leaveRoom is the code for when we exit a room (rooms.c leave_room).
func (g *RogueGame) leaveRoom(cp Coord) {
p := &g.Player
rp := p.Room
if rp.Flags.Has(Maze) {
return
}
var floor byte
switch {
case rp.Flags.Has(Gone):
floor = Passage
case !rp.Flags.Has(Dark) || p.On(Blind):
floor = Floor
default:
floor = ' '
}
p.Room = &g.Level.Passages[*g.Level.FlagsAt(cp.Y, cp.X)&FPassNum]
for y := rp.Pos.Y; y < rp.Max.Y+rp.Pos.Y; y++ {
for x := rp.Pos.X; x < rp.Max.X+rp.Pos.X; x++ {
g.leaveRoomCell(floor, y, x)
}
}
g.doorOpen(rp)
}
// leaveRoomCell hides one square of a room being left (the loop body of
// rooms.c leave_room).
func (g *RogueGame) leaveRoomCell(floor byte, y, x int) {
g.move(y, x)
switch ch := g.inch(); ch {
case Floor:
if floor == ' ' {
g.addch(' ')
}
default:
// to check for monster, we have to strip out the standout
// bit (our Window returns the bare character already)
if !isUpper(ch) {
return
}
if g.Player.On(SenseMonsters) {
g.standout()
g.addch(ch)
g.standend()
return
}
pp := g.Level.At(y, x)
if pp.Ch == Door {
g.addch(Door)
} else {
g.addch(floor)
}
}
}

211
game/run_test.go Normal file
View File

@@ -0,0 +1,211 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"path/filepath"
"strings"
"testing"
)
// fortify makes the hero effectively immortal for a crash-sweep drive:
// game-over now calls myExit and os.Exit(0) (step 8), which would kill the
// test binary, so every death vector is neutralized. Re-applied each turn
// because combat, digestion, freezing, and level drain chip away at these.
func fortify(g *RogueGame) {
p := &g.Player
p.Stats.HP = 30000 // survive combat, arrow/dart traps, bolts
p.Stats.MaxHP = 30000 // survive vampire max-hp drain
p.Stats.Exp = 30000 // survive wraith level drain (death when exp hits 0)
p.FoodLeft = 30000 // never starve
g.NoCommand = 0 // never freeze to death (ice monster / sleep trap)
g.NoMove = 0 // never stay stuck in a bear trap
}
// driveTurns runs the game's per-turn loop up to n times, doing the same
// first-level and pre-play setup Run() does. Run() itself no longer
// returns — game-over exits the process — so tests drive command()
// directly, with short scripts that avoid quitting, saving, or playing
// long enough to starve, any of which would exit the test binary.
func driveTurns(t *testing.T, g *RogueGame, n int) {
t.Helper()
g.startLevel()
g.prePlay()
for range n {
g.command()
}
}
// TestRunDownStairs stands the hero on the staircase and descends via the
// '>' command through the real turn loop, then checks the level changed.
func TestRunDownStairs(t *testing.T) {
t.Parallel()
// '>' is a free action (After=false), so it is followed by a paying
// rest ('.') to end the command() call; without a paying action the
// turn loop would spin forever on the auto-fed prompt input.
tt := &testTerm{input: []byte(">.")}
g := New(Params{Seed: 7, Term: tt})
g.NewLevel()
g.Player.Pos = g.Level.Stairs // stand on the stairs
g.restored = true // keep startLevel from regenerating
g.Daemons = DaemonList{} // and give it a fresh daemon table
g.StartDaemon(DRunners, 0, After)
g.StartDaemon(DDoctor, 0, After)
g.Fuse(DSwander, 0, wanderTime(g), After)
g.StartDaemon(DStomach, 0, After)
driveTurns(t, g, 1)
if g.Depth != 2 {
t.Errorf("depth = %d after descending, want 2", g.Depth)
}
}
// TestScoreRendersList checks that the scoreboard is drawn on the screen
// (in C it went to stdout after endwin; here it stays on the screen). The
// quit and death paths that normally show it now exit the process, so the
// display is exercised through score() directly.
func TestScoreRendersList(t *testing.T) {
t.Parallel()
g := New(Params{Seed: 1, Term: &testTerm{}})
g.Player.Purse = 100
g.score(g.Player.Purse, 1, 0) // flags 1 = quit; posts the top-ten list
found := false
for y := range NumLines {
if strings.Contains(g.scr.Std.Line(y), "Top Ten") {
found = true
}
}
if !found {
t.Error("score list not on screen")
}
}
// TestDeepPlaythrough drives a fortified hero through the real command loop:
// quaff/read/zap on the first level, then descend through the staircase to
// depth 8, saving and restoring mid-way. It is a crash sweep of the turn
// engine, deep level generation, item effects, and mid-game save/restore.
// The hero is fortified so no death exits the process (step 8), and the fixed
// seed keeps it deterministic.
func TestDeepPlaythrough(t *testing.T) {
t.Parallel()
g := New(Params{Seed: 4242, Wizard: true, Term: &testTerm{}})
g.startLevel()
g.prePlay()
fortify(g)
// Stock and use one of each consumable through the command dispatch.
pot := give(g, &Object{Kind: KindPotion, Which: int(PotionHealing)})
scr := give(g, &Object{Kind: KindScroll, Which: int(ScrollMagicMapping)})
wand := newObject()
wand.Kind = KindWand
wand.Which = int(WandLight)
wand.Charges = 5
zap := give(g, wand)
setInput(t, g, 'q', pot) // quaff healing
g.command()
setInput(t, g, 'r', scr) // read magic mapping
g.command()
setInput(t, g, 'z', 'h', zap) // zap the light wand west
g.command()
fortify(g)
// Each consumable identifies itself on use, confirming the q/r/z
// commands actually ran through dispatch (not aborted on a bad prompt).
if !g.Items.Potions[PotionHealing].Know {
t.Error("quaff command did not identify the healing potion")
}
if !g.Items.Scrolls[ScrollMagicMapping].Know {
t.Error("read command did not identify the magic-mapping scroll")
}
if !g.Items.Sticks[WandLight].Know {
t.Error("zap command did not identify the light wand")
}
const wantDepth = 8
for g.Depth < wantDepth {
g.Player.Pos = g.Level.Stairs // stand on the stairs
setInput(t, g, '>', '.') // '>' descends (free), '.' pays the turn
g.command()
fortify(g)
if g.Depth == 4 {
g = saveAndRestore(t, g)
fortify(g)
}
}
if g.Depth != wantDepth {
t.Errorf("depth = %d after descending, want %d", g.Depth, wantDepth)
}
if g.Player.Stats.HP <= 0 {
t.Error("hero died during the playthrough")
}
}
// TestTurnLoopCrashSweep mashes movement, search, and rest through the real
// turn loop for many turns on several seeds, exercising combat, monster AI,
// and traps. The hero is fortified each turn so nothing exits the process,
// and the fixed seeds keep it deterministic; the point is to surface panics.
func TestTurnLoopCrashSweep(t *testing.T) {
t.Parallel()
// A generous mix of movement, search, and rest. The spaces between
// commands double as answers to any --More-- prompt (wait_for eats
// everything up to a space); without them one prompt would swallow the
// rest of the script. The script is long enough that the bounded drive
// never exhausts it (which would spin on the auto-fed prompt input).
script := []byte(strings.Repeat("h j k l y u b n s . ", 400))
for _, seed := range []int32{1, 99, 2026, 31337} {
g := New(Params{Seed: seed, Term: &testTerm{input: script}})
g.startLevel()
g.prePlay()
for range 200 {
fortify(g)
g.command()
}
if g.Player.Stats.HP <= 0 {
t.Errorf("seed %d: hero died despite fortify", seed)
}
}
}
// saveAndRestore snapshots the game to a file, restores it, checks the key
// state survived, and returns the restored game ready to keep playing.
func saveAndRestore(t *testing.T, g *RogueGame) *RogueGame {
t.Helper()
path := filepath.Join(t.TempDir(), "deep.save")
saveErr := g.saveFile(path)
if saveErr != nil {
t.Fatalf("saveFile: %v", saveErr)
}
h, err := Restore(path, Params{Wizard: true, Term: &testTerm{}})
if err != nil {
t.Fatalf("Restore: %v", err)
}
if h.Depth != g.Depth || h.Player.Purse != g.Player.Purse {
t.Errorf("restored game diverged: depth %d/%d purse %d/%d",
h.Depth, g.Depth, h.Player.Purse, g.Player.Purse)
}
return h
}

934
game/save.go Normal file
View File

@@ -0,0 +1,934 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import (
"encoding/gob"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// save.c + state.c — game persistence. The hand-written, XOR-encrypted C
// serializer becomes a gob snapshot; what remains hand-written is exactly
// what state.c's rs_fix_thing did: pointers that alias other structures
// (equipment, chase targets, room membership) are encoded as indices.
// saveFormatVersion identifies the snapshot layout; it changes whenever a
// field rename or retype would make gob silently mis-decode an older
// save ("go2": Object.Type became Kind ObjectKind; "go3": Object.Arm split
// into ArmorClass/Charges/GoldValue/Bonus, damage strings became
// DiceSpec).
const saveFormatVersion = Release + "-go3"
// destRef encodes a monster's chase target (state.c rs_write_thing's
// (kind, index) pairs).
type destRef struct {
Kind int // 0 none, 1 hero, 2 monster index, 3 level-object index, 4 room gold
Idx int
}
// savedCreature is the pointer-free image of a Creature.
type savedCreature struct {
Pos Coord
Turn bool
Type byte
Disguise byte
OldCh byte
Flags CreatureFlags
Stats Stats
RoomIdx int // 0..MaxRooms-1; 100+i for passages; -1 none
Pack []Object
}
// savedPlayer adds the player-only state. The creature half is a named
// field, not an embedded one: gob drops embedded fields of unexported
// types silently.
type savedPlayer struct {
Body savedCreature
CurArmor int // pack indices; -1 = none
CurWeapon int
CurRing [2]int
PackUsed [26]bool
Inpack int
Purse int
FoodLeft int
HungryState int
NoFood int
MaxStats Stats
VfHit int
}
// savedPlace is one map cell without its monster pointer.
type savedPlace struct {
Ch byte
Flags PlaceFlags
}
// SaveState is the complete serialized game (the field list mirrors
// state.c rs_save_file).
type SaveState struct {
Version string
Seed int32
Dnum int
Whoami string
Fruit string
Home string
Wizard bool
Player savedPlayer
Depth int
MaxDepth int
HasAmulet bool
SeenStairs bool
Places []savedPlace
Rooms [MaxRooms]Room
Passages [MaxPass]Room
Objects []Object
Monsters []savedCreature
Dests []destRef // parallel to Monsters
Stairs Coord
TrapCount int
After bool
Again bool
NoScoreF bool
Count int
NoCommand int
NoMove int
Quiet int
Running bool
RunCh byte
DoorStop bool
Firstmove bool
MoveOn bool
ToDeath bool
Kamikaze bool
MaxHit int
Take byte
Delta Coord
DirCh byte
LastComm byte
LastDir byte
LastPick int // pack index, -1
Oldpos Coord
OldrpIdx int
Daemons DaemonList
Options Options
Items ItemLore
Bestiary [26]MonsterKind
Huh string
LastScore int
AllScore bool
Screen []byte // the visible map (Std window contents)
}
// roomIdx encodes a room pointer as an index (state.c find_room_coord).
func (g *RogueGame) roomIdx(rp *Room) int {
if rp == nil {
return -1
}
for i := range g.Level.Rooms {
if rp == &g.Level.Rooms[i] {
return i
}
}
for i := range g.Level.Passages {
if rp == &g.Level.Passages[i] {
return 100 + i
}
}
return -1
}
// roomAt decodes a room index.
func (g *RogueGame) roomAt(i int) *Room {
switch {
case i >= 100:
return &g.Level.Passages[i-100]
case i >= 0:
return &g.Level.Rooms[i]
}
return nil
}
// packIdx finds an object in the player's pack (state.c find_list_ptr).
func (g *RogueGame) packIdx(obj *Object) int {
if obj == nil {
return -1
}
for i, o := range g.Player.Pack {
if o == obj {
return i
}
}
return -1
}
// snapshot captures the complete game state.
func (g *RogueGame) snapshot() *SaveState {
st := g.snapshotHeader()
// the map, sans monster pointers (rebuilt on load)
st.Places = make([]savedPlace, len(g.Level.Places))
for i := range g.Level.Places {
st.Places[i] = savedPlace{
Ch: g.Level.Places[i].Ch,
Flags: g.Level.Places[i].Flags,
}
}
// level objects by value; remember their pointers for dest encoding
objAt := make(map[*Object]int, len(g.Level.Objects))
for i, o := range g.Level.Objects {
st.Objects = append(st.Objects, *o)
objAt[o] = i
}
st.Player = g.snapshotPlayer()
// monsters, with chase targets as (kind, index) references
for _, m := range g.Level.Monsters {
sc := savedCreature{
Pos: m.Pos, Turn: m.Turn, Type: m.Type, Disguise: m.Disguise,
OldCh: m.OldCh, Flags: m.Flags, Stats: m.Stats,
RoomIdx: g.roomIdx(m.Room),
}
for _, o := range m.Pack {
sc.Pack = append(sc.Pack, *o)
}
st.Monsters = append(st.Monsters, sc)
st.Dests = append(st.Dests, g.destRefFor(m, objAt))
}
return st
}
// snapshotHeader captures the scalar game state (the field list of
// state.c rs_save_file).
func (g *RogueGame) snapshotHeader() *SaveState {
return &SaveState{
Version: saveFormatVersion,
Seed: g.Rng.Seed,
Dnum: g.Dnum,
Whoami: g.Whoami,
Fruit: g.Fruit,
Home: g.Home,
Wizard: g.Wizard,
Depth: g.Depth,
MaxDepth: g.MaxDepth,
HasAmulet: g.HasAmulet,
SeenStairs: g.SeenStairs,
Rooms: g.Level.Rooms,
Passages: g.Level.Passages,
Stairs: g.Level.Stairs,
TrapCount: g.Level.TrapCount,
After: g.After,
Again: g.Again,
NoScoreF: g.NoScore,
Count: g.Count,
NoCommand: g.NoCommand,
NoMove: g.NoMove,
Quiet: g.Quiet,
Running: g.Running,
RunCh: g.RunCh,
DoorStop: g.DoorStop,
Firstmove: g.Firstmove,
MoveOn: g.MoveOn,
ToDeath: g.ToDeath,
Kamikaze: g.Kamikaze,
MaxHit: g.MaxHit,
Take: g.Take,
Delta: g.Delta,
DirCh: g.DirCh,
LastComm: g.LastComm,
LastDir: g.LastDir,
LastPick: g.packIdx(g.LastPick),
Oldpos: g.Oldpos,
OldrpIdx: g.roomIdx(g.Oldrp),
Daemons: g.Daemons,
Options: g.Options,
Items: g.Items,
Bestiary: g.Monsters,
Huh: g.Msgs.Huh,
LastScore: g.LastScore,
AllScore: g.AllScore,
Screen: g.scr.Std.Contents(),
}
}
// snapshotPlayer captures the player, equipment as pack indices (the
// player half of snapshot).
func (g *RogueGame) snapshotPlayer() savedPlayer {
p := &g.Player
sp := savedPlayer{
Body: savedCreature{
Pos: p.Pos, Turn: p.Turn, Type: p.Type, Disguise: p.Disguise,
OldCh: p.OldCh, Flags: p.Flags, Stats: p.Stats,
RoomIdx: g.roomIdx(p.Room),
},
CurArmor: g.packIdx(p.CurArmor),
CurWeapon: g.packIdx(p.CurWeapon),
CurRing: [2]int{
g.packIdx(p.CurRing[Left]), g.packIdx(p.CurRing[Right]),
},
PackUsed: p.PackUsed, Inpack: p.Inpack, Purse: p.Purse,
FoodLeft: p.FoodLeft, HungryState: p.HungryState, NoFood: p.NoFood,
MaxStats: p.MaxStats, VfHit: p.VfHit,
}
for _, o := range p.Pack {
sp.Body.Pack = append(sp.Body.Pack, *o)
}
return sp
}
// destRefFor encodes a monster's chase target as a (kind, index)
// reference: the hero, another monster, a level object, or room gold
// (state.c rs_write_thing).
func (g *RogueGame) destRefFor(m *Monster, objAt map[*Object]int) destRef {
switch {
case m.Dest == nil:
return destRef{}
case m.Dest == &g.Player.Pos:
return destRef{Kind: 1}
}
for mi, om := range g.Level.Monsters {
if m.Dest == &om.Pos {
return destRef{Kind: 2, Idx: mi}
}
}
for _, oo := range g.Level.Objects {
if m.Dest == &oo.Pos {
return destRef{Kind: 3, Idx: objAt[oo]}
}
}
for ri := range g.Level.Rooms {
if m.Dest == &g.Level.Rooms[ri].Gold {
return destRef{Kind: 4, Idx: ri}
}
}
return destRef{}
}
// applySnapshot rebuilds live game state from a snapshot.
func (g *RogueGame) applySnapshot(st *SaveState) {
g.applyHeader(st)
for i := range g.Level.Places {
g.Level.Places[i] = Place{
Ch: st.Places[i].Ch,
Flags: st.Places[i].Flags,
}
}
// level objects
g.Level.Objects = nil
for i := range st.Objects {
o := st.Objects[i]
g.Level.Objects = append(g.Level.Objects, &o)
}
g.applyPlayer(st)
g.applyMonsters(st)
g.applyDests(st)
g.scr.Std.SetContents(st.Screen)
}
// applyHeader restores the scalar game state (the field list of
// applySnapshot).
func (g *RogueGame) applyHeader(st *SaveState) {
g.Rng.Seed = st.Seed
g.Dnum = st.Dnum
g.Whoami = st.Whoami
g.Fruit = st.Fruit
g.Home = st.Home
g.Wizard = st.Wizard
g.Depth = st.Depth
g.MaxDepth = st.MaxDepth
g.HasAmulet = st.HasAmulet
g.SeenStairs = st.SeenStairs
g.Level.Rooms = st.Rooms
g.Level.Passages = st.Passages
g.Level.Stairs = st.Stairs
g.Level.TrapCount = st.TrapCount
g.applyTurnState(st)
}
// applyTurnState restores the in-turn command state (the second half of
// applyHeader).
func (g *RogueGame) applyTurnState(st *SaveState) {
g.After = st.After
g.Again = st.Again
g.NoScore = st.NoScoreF
g.Count = st.Count
g.NoCommand = st.NoCommand
g.NoMove = st.NoMove
g.Quiet = st.Quiet
g.Running = st.Running
g.RunCh = st.RunCh
g.DoorStop = st.DoorStop
g.Firstmove = st.Firstmove
g.MoveOn = st.MoveOn
g.ToDeath = st.ToDeath
g.Kamikaze = st.Kamikaze
g.MaxHit = st.MaxHit
g.Take = st.Take
g.Delta = st.Delta
g.DirCh = st.DirCh
g.LastComm = st.LastComm
g.LastDir = st.LastDir
g.Oldpos = st.Oldpos
g.Oldrp = g.roomAt(st.OldrpIdx)
g.Daemons = st.Daemons
g.Options = st.Options
g.Items = st.Items
g.Monsters = st.Bestiary
g.Msgs.Huh = st.Huh
g.LastScore = st.LastScore
g.AllScore = st.AllScore
g.Playing = true
}
// applyPlayer restores the player, resolving equipment pack indices
// (the player half of applySnapshot).
func (g *RogueGame) applyPlayer(st *SaveState) {
p := &g.Player
sp := &st.Player
p.Pos = sp.Body.Pos
p.Turn = sp.Body.Turn
p.Type = sp.Body.Type
p.Disguise = sp.Body.Disguise
p.OldCh = sp.Body.OldCh
p.Flags = sp.Body.Flags
p.Stats = sp.Body.Stats
p.Room = g.roomAt(sp.Body.RoomIdx)
p.Pack = nil
for i := range sp.Body.Pack {
o := sp.Body.Pack[i]
p.Pack = append(p.Pack, &o)
}
pick := func(i int) *Object {
if i < 0 || i >= len(p.Pack) {
return nil
}
return p.Pack[i]
}
p.CurArmor = pick(sp.CurArmor)
p.CurWeapon = pick(sp.CurWeapon)
p.CurRing[Left] = pick(sp.CurRing[0])
p.CurRing[Right] = pick(sp.CurRing[1])
g.LastPick = pick(st.LastPick)
p.PackUsed = sp.PackUsed
p.Inpack = sp.Inpack
p.Purse = sp.Purse
p.FoodLeft = sp.FoodLeft
p.HungryState = sp.HungryState
p.NoFood = sp.NoFood
p.MaxStats = sp.MaxStats
p.VfHit = sp.VfHit
}
// applyMonsters rebuilds the monster list and its map index from a
// snapshot (the monster half of applySnapshot).
func (g *RogueGame) applyMonsters(st *SaveState) {
g.Level.Monsters = nil
for i := range st.Monsters {
sc := &st.Monsters[i]
m := &Monster{Creature: Creature{
Pos: sc.Pos, Turn: sc.Turn, Type: sc.Type, Disguise: sc.Disguise,
OldCh: sc.OldCh, Flags: sc.Flags, Stats: sc.Stats,
Room: g.roomAt(sc.RoomIdx),
}}
for j := range sc.Pack {
o := sc.Pack[j]
m.Pack = append(m.Pack, &o)
}
g.Level.Monsters = append(g.Level.Monsters, m)
g.Level.SetMonsterAt(m.Pos.Y, m.Pos.X, m)
}
}
// applyDests re-aims the monsters' chase targets from their (kind,
// index) references (the fixup half of applySnapshot).
func (g *RogueGame) applyDests(st *SaveState) {
for i, ref := range st.Dests {
m := g.Level.Monsters[i]
switch ref.Kind {
case 1:
m.Dest = &g.Player.Pos
case 2:
m.Dest = &g.Level.Monsters[ref.Idx].Pos
case 3:
m.Dest = &g.Level.Objects[ref.Idx].Pos
case 4:
m.Dest = &g.Level.Rooms[ref.Idx].Gold
}
}
}
// saveAnswer is a yes/no/escape prompt result in the save-game flow.
type saveAnswer int
// The saveGame prompt outcomes.
const (
saveYes saveAnswer = iota
saveNo
saveAbort
)
// saveGame implements the "save game" command (save.c save_game). The
// labeled prompt loop stands in for the C goto over/gotfile flow.
func (g *RogueGame) saveGame() {
g.Msgs.Mpos = 0
prompt:
for {
useDefault := false
if g.FileName != "" {
a := g.askDefaultSave()
if a == saveAbort {
return
}
useDefault = a == saveYes
}
for {
buf, ok := g.saveFileName(useDefault)
if !ok {
return
}
useDefault = false
a := g.saveCheckOverwrite(buf)
if a == saveAbort {
return
}
if a == saveNo {
continue prompt // the C goto over: start again
}
g.FileName = buf
err := g.saveFile(g.FileName)
if err != nil {
g.msg("%s", err.Error())
continue
}
break prompt
}
}
g.myExit()
}
// askDefaultSave asks whether to save to the current file name (save.c
// save_game).
func (g *RogueGame) askDefaultSave() saveAnswer {
for {
g.msg("save file (%s)? ", g.FileName)
c := g.readchar()
g.Msgs.Mpos = 0
switch c {
case Escape:
g.msg("")
return saveAbort
case 'y', 'Y':
g.addstr("Yes\n")
g.refresh()
return saveYes
case 'n', 'N':
return saveNo
}
g.msg("please answer Y or N")
}
}
// saveFileName picks the save path: the default, or a prompted one; ok
// is false when the player quit the prompt (save.c save_game).
func (g *RogueGame) saveFileName(useDefault bool) (string, bool) {
if useDefault {
return g.FileName, true
}
g.Msgs.Mpos = 0
g.msg("file name: ")
buf := ""
if g.getStr(&buf, g.scr.Std) == Quit {
g.msg("")
return "", false
}
g.Msgs.Mpos = 0
return buf, true
}
// saveCheckOverwrite guards an existing file: saveNo restarts the whole
// prompt, saveAbort quits (save.c save_game).
func (g *RogueGame) saveCheckOverwrite(buf string) saveAnswer {
// test to see if the file exists
_, statErr := os.Stat(buf)
if statErr != nil {
return saveYes
}
answer := g.askOverwrite()
if answer != saveYes {
return answer
}
g.msg("file name: %s", buf)
_ = os.Remove(g.FileName) // best effort, as in C (md_unlink)
return saveYes
}
// askOverwrite asks whether to overwrite the existing file (save.c
// save_game).
func (g *RogueGame) askOverwrite() saveAnswer {
for {
g.msg("File exists. Do you wish to overwrite it?")
g.Msgs.Mpos = 0
switch g.readchar() {
case Escape:
g.msg("")
return saveAbort
case 'y', 'Y':
return saveYes
case 'n', 'N':
return saveNo
}
g.msg("Please answer Y or N")
}
}
// saveFile writes the saved game (save.c save_file).
//
// The snapshot goes to a temporary file in the target's own directory and
// is renamed over the target, so there is no instant at which the player
// has no save file: until the rename the old file is whole, and after it
// the new one is. C wrote straight over the target, and this port did the
// same with a remove in front of it (AutoSave), so a write that failed —
// or a signal-time save cut short by the process dying — could leave the
// player with neither the old save nor a usable new one (issue #24).
//
// The temporary file is fsynced before the rename so its contents reach
// the disk ahead of the directory entry that will point at it. The
// directory itself is not fsynced: that would only matter for a machine
// that loses power in the same instant, and the old save survives that
// case anyway. A process killed mid-encode leaves its temporary file
// behind, which is litter next to a destroyed save file, and the dot
// prefix keeps it out of the way.
func (g *RogueGame) saveFile(path string) error {
f, err := os.CreateTemp(filepath.Dir(path), ".rogue-save-*")
if err != nil {
return err
}
tmp := f.Name()
writeErr := writeSnapshotFile(f, g.snapshot())
if writeErr != nil {
_ = os.Remove(tmp) // never leave a half-written file behind
return writeErr
}
renErr := os.Rename(tmp, path)
if renErr != nil {
_ = os.Remove(tmp)
return renErr
}
return nil
}
// writeSnapshotFile writes the snapshot into an open temporary file: it
// encodes, fsyncs so the bytes reach the disk before the caller renames
// the file into place, chmods it read-only as the C game's saves were
// (save.c save_file), and closes it. It never removes the file: its
// caller owns the cleanup, so that one place decides what happens to a
// failed write.
func writeSnapshotFile(f *os.File, st *SaveState) error {
encErr := gob.NewEncoder(f).Encode(st)
if encErr == nil {
encErr = f.Sync()
}
if encErr == nil {
encErr = f.Chmod(0o400)
}
closeErr := f.Close()
if encErr != nil {
return encErr
}
return closeErr
}
// autoSaveRequest is one signal-triggered autosave in flight: the signal
// goroutine posts it and waits, the game goroutine performs the save and
// closes done. ok is written before done is closed and read only after,
// so the close is the happens-before edge that publishes it.
type autoSaveRequest struct {
done chan struct{}
ok bool
}
// AutoSaveOnSignal asks the game goroutine to autosave and waits up to
// timeout for it to finish, reporting whether the save actually ran
// (save.c auto_save, the SIGHUP/SIGTERM handler). It is the only entry
// point the signal goroutine may use, and it deliberately touches no game
// state: the gob encoder used to walk the live game tree from the signal
// goroutine while the game goroutine was mid-turn mutating it (issue
// #24).
//
// Blocked on input is the case that matters, since a dropped connection
// is the whole reason the handler exists: the request is posted first and
// the input read is then interrupted, so a game goroutine parked in
// ReadChar wakes, saves in readchar, and reads again. A game goroutine
// that is running turns instead picks the request up between turns, in
// command; one parked in the `!` shell escape picks it up in
// runShellEscape.
//
// The wait is bounded because the signal goroutine's job is to get the
// process out. If the game goroutine is somewhere with no service point
// at all, the deadline expires, this reports false, and the caller
// restores the terminal and exits — leaving the player's previous save
// file exactly as it was, which is the point of the rename in saveFile.
func (g *RogueGame) AutoSaveOnSignal(timeout time.Duration) bool {
req := &autoSaveRequest{done: make(chan struct{})}
select {
case g.sigSave <- req:
default:
// A request is already queued and unserviced, or there is no
// game loop to service one; either way this one would not be
// answered either.
return false
}
g.scr.Interrupt()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-req.done:
return req.ok
case <-timer.C:
return false
}
}
// serviceAutoSaveRequest performs a pending signal-triggered autosave, if
// one is waiting, and otherwise returns at once. It runs on the game
// goroutine — that is the whole design — so it must only be called where
// that goroutine is not itself inside the encode: between turns, or while
// parked waiting for input or for the shell escape.
//
// What is guaranteed, exactly: the encode runs on the one goroutine that
// owns the state, so the snapshot is internally consistent and always
// restorable. It is *not* guaranteed to be a between-commands snapshot.
// Only one of the three service points gives that: the check at the top
// of command, which runs after the previous command returned and before
// this turn's DoDaemons(Before)/DoFuses(Before). The other two are both
// reached from inside a command call already under way, and both cost
// the same on restore.
//
// readchar is reached from prompts raised part-way through a command —
// --More-- on the second message of a turn, askOverwrite, getStr, the
// direction and pack prompts — and by then the command has already
// mutated state: fight sets g.Count and g.Quiet and runs runTo before
// any message, revealXeroc writes tp.Disguise before emitting one. The
// ordinary top-of-turn key read in readCommand is inside command too,
// after that turn's BEFORE daemons and turnUpkeep.
//
// runShellEscape is no safer. shell is an ordinary command handler ('!'
// in the tables.go dispatch table), reached through executeCommand, so a
// goroutine parked in the shell escape has already run this turn's
// DoDaemons(Before), DoFuses(Before), turnUpkeep and the last-command
// bookkeeping, and has not yet run DoDaemons(After), DoFuses(After) or
// ringTurnEffects.
//
// The cost, at both: restoring re-enters playit at the top of command,
// so the rest of that command never runs — its AFTER daemons and fuses
// and its ring effects are lost — and the restored game opens with a
// fresh BEFORE pass on top of the one already in the snapshot. That
// second BEFORE pass is not free: rollwand, a live Before daemon once
// swander has fired, ticks again and draws from the RNG every fourth
// tick, and any Before fuse is decremented again.
//
// Not every consequence of that pass is shared by both, though. visuals
// returns immediately unless g.After, and After is part of the snapshot,
// so DVisuals never re-ticks after a shell-escape save: shell sets
// g.After = false as its first statement, before it parks. After a
// readchar save it usually does re-tick, because turnUpkeep sets
// g.After = true just before the top-of-turn read; the exception is a
// handler that clears After before prompting, as identifyTrapCommand
// does ahead of promptDirection.
//
// The result is still a coherent game state, one turn's worth of effects
// off — strictly better than the torn encode this replaced, and the cost
// of being able to save a player whose line dropped mid-prompt, or who
// is away in a shell, at all.
func (g *RogueGame) serviceAutoSaveRequest() {
select {
case req := <-g.sigSave:
g.runAutoSaveRequest(req)
default:
}
}
// runAutoSaveRequest answers one request: save, then release the waiter.
func (g *RogueGame) runAutoSaveRequest(req *autoSaveRequest) {
req.ok = g.autoSave()
close(req.done)
}
// autoSave silently saves to the current file name (save.c auto_save),
// reporting whether it wrote a save. Game-goroutine only — reach it
// through AutoSaveOnSignal from anywhere else.
//
// The error is not surfaced: there is no player to tell, since the
// terminal is on its way out, and nothing sensible to do about it. It is
// reported to the waiting signal goroutine as a failed save rather than
// discarded outright, which is what the old `_ =` here used to do.
func (g *RogueGame) autoSave() bool {
if g.FileName == "" {
return false
}
return g.saveFile(g.FileName) == nil
}
// ErrSaveOutOfDate reports a save file from an incompatible version.
var ErrSaveOutOfDate = errors.New("sorry, saved game is out of date")
// ErrSaveCorrupt reports a save file whose contents are structurally
// impossible for a game this code could have written.
var ErrSaveCorrupt = errors.New("sorry, saved game is corrupt")
// validateSnapshotObjects rejects a snapshot carrying an item whose Which
// would index past the end of its kind's tables. The file is written by
// this program, so such a value can only come from corruption or
// tampering; refusing it at the door is what keeps a malformed object
// from reaching the effect tables in doZap, quaff, and readScroll, where
// the wizard-create bug (issue #10) used to put one.
func validateSnapshotObjects(st *SaveState) error {
lists := make([][]Object, 0, 2+len(st.Monsters))
lists = append(lists, st.Objects, st.Player.Body.Pack)
for i := range st.Monsters {
lists = append(lists, st.Monsters[i].Pack)
}
for _, list := range lists {
for i := range list {
obj := &list[i]
if !obj.hasValidWhich() {
return fmt.Errorf("%w: %s has out-of-range which %d",
ErrSaveCorrupt, obj.Kind, obj.Which)
}
}
}
return nil
}
// Restore restores a saved game from a file (save.c restore). The file is
// deleted, as in C, to defeat restarting from the same save.
func Restore(path string, params Params) (*RogueGame, error) {
f, err := os.Open(path) //nolint:gosec // G304: the save path is user-chosen by design
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }() // read-only handle
var st SaveState
decErr := gob.NewDecoder(f).Decode(&st)
if decErr != nil {
return nil, fmt.Errorf("%s: corrupt or incompatible save file: %w",
path, decErr)
}
if st.Version != saveFormatVersion {
return nil, ErrSaveOutOfDate
}
valErr := validateSnapshotObjects(&st)
if valErr != nil {
return nil, valErr
}
g := &RogueGame{
data: newGameData(),
Rng: &Rng{},
Playing: true,
ScorePath: params.ScorePath,
FileName: path,
rogueOpts: params.RogueOpts,
restored: true,
sigSave: make(chan *autoSaveRequest, 1),
}
g.scr = NewScreen(params.Term)
g.Msgs.attach(g.scr, g.look, g.readchar)
g.applySnapshot(&st)
// defeat multiple restarting from the same place
rmErr := os.Remove(path)
if rmErr != nil {
return nil, fmt.Errorf("cannot unlink file: %w", rmErr)
}
return g, nil
}

166
game/save_test.go Normal file
View File

@@ -0,0 +1,166 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"encoding/gob"
"os"
"path/filepath"
"testing"
)
func TestSaveRestoreRoundTrip(t *testing.T) {
t.Parallel()
g := mkGame(t, 4242)
// Dirty up some state so the round trip is meaningful.
g.Player.Purse = 123
g.Player.FoodLeft = 777
g.HasAmulet = true
g.Items.Potions[PotionHealing].Know = true
g.Items.Scrolls[ScrollMagicMapping].Guess = "map???"
g.Monsters['F'-'A'].Stats.Dmg = dice("3x1") // mutated bestiary must survive
if len(g.Level.Monsters) > 0 {
g.Level.Monsters[0].Flags.Set(Awake)
g.Level.Monsters[0].Dest = &g.Player.Pos
}
path := filepath.Join(t.TempDir(), "rogue.save")
saveErr := g.saveFile(path)
if saveErr != nil {
t.Fatalf("saveFile: %v", saveErr)
}
h, err := Restore(path, Params{Term: &testTerm{}})
if err != nil {
t.Fatalf("Restore: %v", err)
}
_, statErr := os.Stat(path)
if !os.IsNotExist(statErr) {
t.Error("save file not deleted on restore (C anti-restart rule)")
}
checkRestoredState(t, g, h)
checkRestoredMonsters(t, g, h)
checkEquipmentAliasing(t, g, h)
}
// checkRestoredState verifies the scalar state survived the round trip.
func checkRestoredState(t *testing.T, g, h *RogueGame) {
t.Helper()
if h.Player.Purse != 123 || h.Player.FoodLeft != 777 {
t.Errorf("player state lost: purse=%d food=%d",
h.Player.Purse, h.Player.FoodLeft)
}
if !h.HasAmulet {
t.Error("amulet flag lost")
}
if !h.Items.Potions[PotionHealing].Know {
t.Error("potion identification lost")
}
if h.Items.Scrolls[ScrollMagicMapping].Guess != "map???" {
t.Error("scroll guess lost")
}
if h.Monsters['F'-'A'].Stats.Dmg.String() != "3x1" {
t.Error("mutated bestiary lost")
}
if h.Rng.Seed != g.Rng.Seed {
t.Error("RNG state lost")
}
if renderMap(h) != renderMap(g) {
t.Error("restored level map differs")
}
}
// checkRestoredMonsters verifies the monster list and its pointer fixups
// survived the round trip.
func checkRestoredMonsters(t *testing.T, g, h *RogueGame) {
t.Helper()
if len(h.Level.Monsters) != len(g.Level.Monsters) {
t.Fatalf("monster count %d != %d",
len(h.Level.Monsters), len(g.Level.Monsters))
}
if len(g.Level.Monsters) == 0 {
return
}
m := h.Level.Monsters[0]
if m.Dest != &h.Player.Pos {
t.Error("monster chase target not re-aliased to the hero")
}
if h.Level.MonsterAt(m.Pos.Y, m.Pos.X) != m {
t.Error("map monster index not rebuilt")
}
if m.Room == nil {
t.Error("monster room pointer not rebuilt")
}
}
// checkEquipmentAliasing verifies the wielded mace is the same *Object as
// the one in the pack.
func checkEquipmentAliasing(t *testing.T, g, h *RogueGame) {
t.Helper()
st := g.snapshot()
t.Logf("snapshot indices: weapon=%d armor=%d rings=%v packlen=%d",
st.Player.CurWeapon, st.Player.CurArmor, st.Player.CurRing,
len(st.Player.Body.Pack))
t.Logf("restored: CurWeapon=%p pack has %d items", h.Player.CurWeapon,
len(h.Player.Pack))
found := false
for i, o := range h.Player.Pack {
t.Logf(" pack[%d]=%p type=%v which=%d", i, o, o.Kind, o.Which)
if o == h.Player.CurWeapon {
found = true
}
}
if !found {
t.Error("restored CurWeapon is not aliased into the pack")
}
}
func TestRestoreRejectsWrongVersion(t *testing.T) {
t.Parallel()
g := mkGame(t, 1)
path := filepath.Join(t.TempDir(), "rogue.save")
st := g.snapshot()
st.Version = "0.0.0"
f, err := os.Create(path) //nolint:gosec // G304: test temp path
if err != nil {
t.Fatal(err)
}
encErr := gob.NewEncoder(f).Encode(st)
if encErr != nil {
t.Fatal(encErr)
}
closeErr := f.Close()
if closeErr != nil {
t.Fatal(closeErr)
}
_, restoreErr := Restore(path, Params{})
if restoreErr == nil {
t.Error("restore accepted an out-of-date save")
}
}

260
game/score.go Normal file
View File

@@ -0,0 +1,260 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import (
"encoding/gob"
"fmt"
"os"
"time"
)
// score.h / rip.c score() / save.c rd_score+wr_score — the scoreboard.
// The C scorefile was XOR-encrypted structs; the port stores a gob list at
// ScorePath (empty path disables persistence but still shows the list).
// numScores is the C numscores default: the top ten.
const numScores = 10
// ScoreEnt is score.h struct sc_ent.
type ScoreEnt struct {
UID int
Score int
Flags int // 0=killed, 1=quit, 2=winner, 3=killed with amulet
Monster byte
Name string
Level int
Time int64
}
// rdScore reads the scoreboard file (save.c rd_score).
func (g *RogueGame) rdScore() []ScoreEnt {
topTen := make([]ScoreEnt, numScores)
if g.ScorePath == "" {
return topTen
}
f, err := os.Open(g.ScorePath)
if err != nil {
return topTen
}
defer func() { _ = f.Close() }() // read-only handle
var onDisk []ScoreEnt
decErr := gob.NewDecoder(f).Decode(&onDisk)
if decErr != nil {
return topTen // unreadable scoreboard reads as empty, as in C
}
copy(topTen, onDisk)
return topTen
}
// wrScore writes the scoreboard file (save.c wr_score).
func (g *RogueGame) wrScore(topTen []ScoreEnt) {
if g.ScorePath == "" {
return
}
// lock_sc/unlock_sc: exclusive-create lock file with stale takeover.
// The whole scoreboard write is best effort, as it was in C: a shared
// scoreboard must never take the game down.
lock := g.ScorePath + ".lck"
for range 5 {
lf, err := os.OpenFile(lock, //nolint:gosec // G304: configured path
os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
_ = lf.Close()
defer func() { _ = os.Remove(lock) }()
break
}
fi, statErr := os.Stat(lock)
if statErr == nil && time.Since(fi.ModTime()) > staleLockAge {
_ = os.Remove(lock)
continue
}
time.Sleep(time.Second)
}
f, err := os.OpenFile(g.ScorePath,
os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return
}
_ = gob.NewEncoder(f).Encode(topTen)
_ = f.Close()
}
// staleLockAge is how old a scoreboard lock file may be before another
// process assumes its owner died and takes it over (mach_dep.c lock_sc
// aged its lock the same way).
const staleLockAge = 10 * time.Second
// score figures the score and posts it (rip.c score). flags -1 means just
// display the list (the -s command line option).
func (g *RogueGame) score(amount, flags int, monst byte) {
if flags >= 0 && g.scr != nil && g.scr.term != nil {
g.mvaddstr(NumLines-1, 0, "[Press return to continue]")
g.refresh()
g.waitFor('\n')
}
topTen := g.rdScore()
// Insert her in list if need be
ins := -1
if !g.NoScore && flags >= 0 {
ins = g.scoreInsert(topTen, amount, flags, monst)
}
lines, highlight := g.scoreLines(topTen, ins)
g.showScores(lines, highlight)
// Update the list file
if ins >= 0 {
g.wrScore(topTen)
}
}
// scoreInsert slots the new score into the top ten, honoring the
// one-score-per-losing-uid rule; -1 means it did not place (the
// insertion half of rip.c score).
func (g *RogueGame) scoreInsert(topTen []ScoreEnt, amount, flags int, monst byte) int {
uid := os.Getuid()
scp := g.scoreSlot(topTen, amount, flags, uid)
if scp >= len(topTen) {
return -1
}
sc2 := len(topTen) - 1
if flags != 2 && !g.AllScore {
for i := scp; i < len(topTen); i++ {
if topTen[i].UID == uid && topTen[i].Flags != 2 {
sc2 = i
break
}
}
}
for sc2 > scp {
topTen[sc2] = topTen[sc2-1]
sc2--
}
lvl := g.Depth
if flags == 2 {
lvl = g.MaxDepth
}
topTen[scp] = ScoreEnt{
UID: uid,
Score: amount,
Flags: flags,
Monster: monst,
Name: g.Whoami,
Level: lvl,
Time: time.Now().Unix(),
}
return scp
}
// scoreSlot finds where the new score lands: len(topTen) when it does
// not place, or when this uid already holds a losing score (the scan of
// rip.c score).
func (g *RogueGame) scoreSlot(topTen []ScoreEnt, amount, flags, uid int) int {
for i := range topTen {
if amount > topTen[i].Score {
return i
}
if !g.AllScore && flags != 2 &&
topTen[i].UID == uid && topTen[i].Flags != 2 {
// only one score per nowin uid
return len(topTen)
}
}
return len(topTen)
}
// scoreLines formats the scoreboard, noting which display line holds
// the freshly inserted score (the display half of rip.c score).
func (g *RogueGame) scoreLines(topTen []ScoreEnt, ins int) ([]string, int) {
label := "Rogueists"
if g.AllScore {
label = "Scores"
}
lines := []string{
fmt.Sprintf("Top Ten %s:", label),
" Score Name",
}
highlight := -1
for i := range topTen {
scp := &topTen[i]
if scp.Score == 0 {
break
}
line := fmt.Sprintf("%2d %5d %s: %s on level %d", i+1,
scp.Score, scp.Name, g.data.scoreReasons[scp.Flags], scp.Level)
if scp.Flags == 0 || scp.Flags == 3 {
line += " by " + g.killname(scp.Monster, true)
}
line += "."
if i == ins {
highlight = len(lines)
}
lines = append(lines, line)
}
return lines, highlight
}
// showScores prints the scoreboard on the screen when there is one,
// else to standard output (rip.c score).
func (g *RogueGame) showScores(lines []string, highlight int) {
if g.scr == nil || g.scr.term == nil {
for _, line := range lines {
_, _ = fmt.Fprintln(os.Stdout, line) // CLI output
}
return
}
g.clear()
for i, line := range lines {
if i == highlight {
g.standout()
}
g.mvaddstr(i, 0, line)
if i == highlight {
g.standend()
}
}
g.refresh()
}
// ShowScores implements the -s command line option: print the scoreboard
// and nothing else.
func (g *RogueGame) ShowScores() {
g.NoScore = true
g.score(0, -1, 0)
}

283
game/screen.go Normal file
View File

@@ -0,0 +1,283 @@
package game
import "fmt"
// The screen layer replaces curses. Game code draws into Window cell
// buffers (stdscr and the hw scratch window); a Terminal implementation
// blits them to a real device. Tests run with a scripted Terminal (or none
// at all), which is also how the "screen is a data structure" idiom —
// C code reading back what it drew with inch() — stays intact headlessly.
// Terminal is the physical device: a tcell screen in the real game, a
// script in tests.
type Terminal interface {
// Render blits the window to the device.
Render(w *Window)
// ReadChar blocks for the next key, translated to Rogue's input bytes
// (arrows become hjkl, control keys their C0 codes). ok is false when
// the read was woken by Interrupt instead of by a key, which is how a
// signal-triggered autosave reaches a game parked on input; the byte
// is meaningless then.
ReadChar() (ch byte, ok bool)
// Interrupt wakes a ReadChar that is blocked waiting for a key. It is
// 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()
}
// cell is one screen position.
type cell struct {
ch byte
standout bool
}
// Window is an in-memory curses window: a cell grid with a cursor and a
// standout attribute.
type Window struct {
rows, cols int
cells []cell
cy, cx int
standout bool
}
// NewWindow returns a cleared window.
func NewWindow(rows, cols int) *Window {
w := &Window{rows: rows, cols: cols, cells: make([]cell, rows*cols)}
w.Clear()
return w
}
// Move positions the cursor (curses move/wmove).
func (w *Window) Move(y, x int) { w.cy, w.cx = y, x }
// GetYX reports the cursor position (curses getyx).
func (w *Window) GetYX() (int, int) { return w.cy, w.cx }
// AddCh writes a character at the cursor and advances it (curses addch).
func (w *Window) AddCh(ch byte) {
if ch == '\n' {
w.cy, w.cx = w.cy+1, 0
return
}
if w.cy < 0 || w.cy >= w.rows || w.cx < 0 || w.cx >= w.cols {
return
}
*w.at(w.cy, w.cx) = cell{ch: ch, standout: w.standout}
if w.cx++; w.cx >= w.cols {
w.cx = 0
if w.cy < w.rows-1 {
w.cy++
}
}
}
// AddStr writes a string at the cursor (curses addstr).
func (w *Window) AddStr(s string) {
for i := range len(s) {
w.AddCh(s[i])
}
}
// MvAddCh moves then writes (curses mvaddch).
func (w *Window) MvAddCh(y, x int, ch byte) {
w.Move(y, x)
w.AddCh(ch)
}
// MvAddStr moves then writes (curses mvaddstr).
func (w *Window) MvAddStr(y, x int, s string) {
w.Move(y, x)
w.AddStr(s)
}
// Printwf writes formatted text at the cursor (curses printw).
func (w *Window) Printwf(format string, a ...any) {
w.AddStr(fmt.Sprintf(format, a...))
}
// MvPrintwf moves then writes formatted text (curses mvprintw).
func (w *Window) MvPrintwf(y, x int, format string, a ...any) {
w.Move(y, x)
w.Printwf(format, a...)
}
// Inch returns the character under the cursor (curses inch, sans
// attributes — the C code always strips them with CCHAR).
func (w *Window) Inch() byte {
if w.cy < 0 || w.cy >= w.rows || w.cx < 0 || w.cx >= w.cols {
return ' '
}
return w.at(w.cy, w.cx).ch
}
// MvInch moves then reads (curses mvinch).
func (w *Window) MvInch(y, x int) byte {
w.Move(y, x)
return w.Inch()
}
// Standout sets or clears the standout attribute for subsequent writes
// (curses standout/standend).
func (w *Window) Standout(on bool) { w.standout = on }
// Clear blanks the window and homes the cursor (curses clear/wclear).
func (w *Window) Clear() {
for i := range w.cells {
w.cells[i] = cell{ch: ' '}
}
w.cy, w.cx = 0, 0
}
// Clrtoeol blanks from the cursor to the end of the line (curses clrtoeol).
func (w *Window) Clrtoeol() {
if w.cy < 0 || w.cy >= w.rows {
return
}
for x := w.cx; x < w.cols; x++ {
*w.at(w.cy, x) = cell{ch: ' '}
}
}
// CopyFrom copies another window's contents (curses overwrite).
func (w *Window) CopyFrom(src *Window) {
copy(w.cells, src.cells)
}
// Size reports the window dimensions as rows, columns.
func (w *Window) Size() (int, int) { return w.rows, w.cols }
// CellAt reports the character and standout attribute at a position; used
// by Terminal implementations to render the window.
func (w *Window) CellAt(y, x int) (byte, bool) {
c := w.at(y, x)
return c.ch, c.standout
}
// Contents dumps the window characters row-major (the save file keeps the
// visible map, as the C game saved the curses screen).
func (w *Window) Contents() []byte {
out := make([]byte, len(w.cells))
for i, c := range w.cells {
out[i] = c.ch
}
return out
}
// SetContents restores a Contents dump.
func (w *Window) SetContents(data []byte) {
for i := range w.cells {
if i < len(data) {
w.cells[i] = cell{ch: data[i]}
}
}
}
// Line returns row y as a trimmed string; used by tests and the death/
// victory screens.
func (w *Window) Line(y int) string {
buf := make([]byte, w.cols)
for x := range w.cols {
buf[x] = w.at(y, x).ch
}
return string(buf)
}
// at addresses the cell at (y, x) in the backing array.
func (w *Window) at(y, x int) *cell { return &w.cells[y*w.cols+x] }
// Screen bundles the two windows the game draws on with the device that
// shows them.
type Screen struct {
term Terminal
Std *Window // stdscr: the dungeon view
Hw *Window // hw: the scratch window for overlays
}
// NewScreen builds the standard 24x80 game screen.
func NewScreen(term Terminal) *Screen {
return &Screen{
term: term,
Std: NewWindow(NumLines, NumCols),
Hw: NewWindow(NumLines, NumCols),
}
}
// Refresh pushes stdscr to the device (curses refresh).
func (s *Screen) Refresh() {
if s.term != nil {
s.term.Render(s.Std)
}
}
// 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 {
s.term.Fini()
}
}
// Interrupt wakes a device read that is blocked waiting for a key, if
// there is a device. Called from the signal goroutine; everything else on
// Screen belongs to the game goroutine.
func (s *Screen) Interrupt() {
if s.term != nil {
s.term.Interrupt()
}
}
// RefreshWin pushes an arbitrary window to the device (curses wrefresh).
func (s *Screen) RefreshWin(w *Window) {
if s.term != nil {
s.term.Render(w)
}
}
// Thin RogueGame wrappers so ported bodies keep their curses shape.
func (g *RogueGame) move(y, x int) { g.scr.Std.Move(y, x) }
func (g *RogueGame) addch(ch byte) { g.scr.Std.AddCh(ch) }
func (g *RogueGame) addstr(s string) { g.scr.Std.AddStr(s) }
func (g *RogueGame) mvaddch(y, x int, c byte) { g.scr.Std.MvAddCh(y, x, c) }
func (g *RogueGame) mvaddstr(y, x int, s string) {
g.scr.Std.MvAddStr(y, x, s)
}
func (g *RogueGame) printw(f string, a ...any) { g.scr.Std.Printwf(f, a...) }
func (g *RogueGame) inch() byte { return g.scr.Std.Inch() }
func (g *RogueGame) mvinch(y, x int) byte { return g.scr.Std.MvInch(y, x) }
func (g *RogueGame) standout() { g.scr.Std.Standout(true) }
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() }

383
game/scrolls.go Normal file
View File

@@ -0,0 +1,383 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// scrolls.c — read a scroll and let it happen.
// readScroll reads a scroll from the pack and does the appropriate thing
// (scrolls.c read_scroll).
func (g *RogueGame) readScroll() {
p := &g.Player
obj, ok := g.promptPackItem("read", KindScroll)
if !ok {
return
}
if obj.Kind != KindScroll {
if !g.Options.Terse {
g.msg("there is nothing on it to read")
} else {
g.msg("nothing to read")
}
return
}
// Calculate the effect it has on the poor guy.
if obj == p.CurWeapon {
p.CurWeapon = nil
}
// Get rid of the thing
g.leavePack(obj, false, false)
if h := g.data.readHandler(obj); h != nil {
h(g, obj)
}
g.look(true) // put the result of the scroll on the screen
g.status()
// A malformed scroll has no lore entry to name (see quaff).
if obj.hasValidWhich() {
g.callIt(&g.Items.Scrolls[obj.Which])
}
}
// The per-scroll effect handlers, dispatched through
// gameData.readHandlers. Each is one case of the C read_scroll switch.
func (g *RogueGame) readMonsterConfusion(*Object) {
// Scroll of monster confusion. Give him that power.
g.Player.Flags.Set(CanConfuse)
g.msg("your hands begin to glow %s", g.pickColor("red"))
}
func (g *RogueGame) readEnchantArmor(*Object) {
p := &g.Player
if p.CurArmor != nil {
p.CurArmor.ArmorClass--
p.CurArmor.Flags.Clear(Cursed)
g.msg("your armor glows %s for a moment", g.pickColor("silver"))
}
}
func (g *RogueGame) readHoldMonster(*Object) {
// Hold monster scroll. Stop all monsters within two spaces from
// chasing after the hero.
held := g.holdMonstersNear()
if held > 0 {
g.addmsgf("the monster")
if held > 1 {
g.addmsgf("s around you")
}
g.addmsgf(" freeze")
if held == 1 {
g.addmsgf("s")
}
g.endmsg()
g.Items.Scrolls[ScrollHoldMonster].Know = true
} else {
g.msg("you feel a strange sense of loss")
}
}
// holdMonstersNear freezes every awake monster within two spaces of the
// hero and reports how many froze (the scan of the C S_HOLD case).
func (g *RogueGame) holdMonstersNear() int {
p := &g.Player
held := 0
for x := p.Pos.X - 2; x <= p.Pos.X+2; x++ {
if x < 0 || x >= NumCols {
continue
}
for y := p.Pos.Y - 2; y <= p.Pos.Y+2; y++ {
if y < 0 || y > NumLines-1 {
continue
}
if mp := g.Level.MonsterAt(y, x); mp != nil && mp.On(Awake) {
mp.Flags.Clear(Awake)
mp.Flags.Set(Held)
held++
}
}
}
return held
}
func (g *RogueGame) readSleep(*Object) {
// Scroll which makes you fall asleep
g.Items.Scrolls[ScrollSleep].Know = true
g.NoCommand += g.rnd(g.spread(5)) + 4 // SLEEPTIME
g.Player.Flags.Clear(Awake)
g.msg("you fall asleep")
}
func (g *RogueGame) readCreateMonster(*Object) {
// Create a monster: first look in a circle around him, next try
// his room, otherwise give up
mp, ok := g.createMonsterSpot()
if !ok {
g.msg("you hear a faint cry of anguish in the distance")
} else {
tp := &Monster{}
g.newMonster(tp, g.randMonster(false), mp)
}
}
// createMonsterSpot reservoir-samples a legal spot around the hero for a
// created monster; ok is false when every neighbor is blocked (the scan
// of the C S_CREATE case).
func (g *RogueGame) createMonsterSpot() (Coord, bool) {
p := &g.Player
i := 0
var mp Coord
for y := p.Pos.Y - 1; y <= p.Pos.Y+1; y++ {
for x := p.Pos.X - 1; x <= p.Pos.X+1; x++ {
// Don't put a monster on top of the player.
if y == p.Pos.Y && x == p.Pos.X {
continue
}
// Or anything else nasty
if ch := g.Level.VisibleChar(y, x); stepOk(ch) {
if ch == Scroll {
if fo := g.Level.ObjectAt(y, x); fo != nil &&
fo.ScrollKind() == ScrollScareMonster {
continue
}
}
if i++; g.rnd(i) == 0 {
mp = Coord{Y: y, X: x}
}
}
}
}
return mp, i != 0
}
func (g *RogueGame) readIdentify(obj *Object) {
// Identify, let him figure something out. idType is shorter than the
// scroll table keying it (it stops after the last identify scroll),
// so the filter lookup carries its own bound. That bound is not
// reachable today: readHandlers registers readIdentify only for the
// identify scrolls, all of which sit inside idType. It is kept as
// defense-in-depth against a future table resize.
g.Items.Scrolls[obj.Which].Know = true
g.msg("this scroll is an %s scroll", g.Items.Scrolls[obj.Which].Name)
g.whatis(true, g.data.identifyType(obj.ScrollKind()))
}
func (g *RogueGame) readMagicMapping(*Object) {
// Scroll of magic mapping.
g.Items.Scrolls[ScrollMagicMapping].Know = true
g.msg("oh, now this scroll has a map on it")
// take all the things we want to keep hidden out of the window
for y := 1; y < NumLines-1; y++ {
for x := range NumCols {
g.revealSpot(y, x)
}
}
}
// revealSpot uncovers one map cell for magic mapping and draws it (the
// loop body of the C SCR_MAP case).
func (g *RogueGame) revealSpot(y, x int) {
pp := g.Level.At(y, x)
ch := revealChar(pp)
if ch != ' ' {
if tp := pp.Monst; tp != nil {
tp.OldCh = ch
if !g.Player.On(SenseMonsters) {
g.mvaddch(y, x, ch)
}
} else {
g.mvaddch(y, x, ch)
}
}
}
// revealChar decides what magic mapping shows at a cell, making secret
// doors, hidden passages, and hidden traps real as a side effect; ' '
// means show nothing (the switch of the C SCR_MAP loop).
func revealChar(pp *Place) byte {
ch := pp.Ch
pass := false
switch ch {
case Door, Stairs:
case '-', '|':
ch = revealWall(pp)
case ' ':
pass = revealSolid(pp)
case Passage:
pass = true
case Floor:
ch = revealFloor(pp)
default:
if pp.Flags.Has(FPassage) {
pass = true
} else {
ch = ' '
}
}
if pass {
if !pp.Flags.Has(FReal) {
pp.Ch = Passage
}
pp.Flags.Set(FSeen | FReal)
ch = Passage
}
return ch
}
// revealWall handles a wall cell for magic mapping: a secret door
// becomes a real door (the '-'/'|' arm).
func revealWall(pp *Place) byte {
if !pp.Flags.Has(FReal) {
pp.Ch = Door
pp.Flags.Set(FReal)
return Door
}
return pp.Ch
}
// revealSolid handles a blank cell for magic mapping, reporting whether
// it is passage: hidden passages become real; hidden things in walls
// stay hidden (the ' ' arm).
func revealSolid(pp *Place) bool {
if pp.Flags.Has(FReal) {
return pp.Flags.Has(FPassage)
}
pp.Flags.Set(FReal)
pp.Ch = Passage
return true
}
// revealFloor handles a floor cell for magic mapping: a hidden trap
// becomes a real, seen trap; real floor shows nothing (the FLOOR arm).
func revealFloor(pp *Place) byte {
if pp.Flags.Has(FReal) {
return ' '
}
pp.Ch = Trap
pp.Flags.Set(FSeen | FReal)
return Trap
}
func (g *RogueGame) readFoodDetection(*Object) {
// Food detection
found := false
g.scr.Hw.Clear()
for _, fo := range g.Level.Objects {
if fo.Kind == KindFood {
found = true
g.scr.Hw.MvAddCh(fo.Pos.Y, fo.Pos.X, Food)
}
}
if found {
g.Items.Scrolls[ScrollFoodDetection].Know = true
g.showWin("Your nose tingles and you smell food.--More--")
} else {
g.msg("your nose tingles")
}
}
func (g *RogueGame) readTeleportation(*Object) {
// Scroll of teleportation: make him disappear and reappear
p := &g.Player
curRoom := p.Room
g.teleport()
if curRoom != p.Room {
g.Items.Scrolls[ScrollTeleportation].Know = true
}
}
func (g *RogueGame) readEnchantWeapon(*Object) {
p := &g.Player
if p.CurWeapon == nil || p.CurWeapon.Kind != KindWeapon {
g.msg("you feel a strange sense of loss")
} else {
p.CurWeapon.Flags.Clear(Cursed)
if g.rnd(2) == 0 {
p.CurWeapon.HPlus++
} else {
p.CurWeapon.DPlus++
}
g.msg("your %s glows %s for a moment",
g.Items.Weapons[p.CurWeapon.Which].Name, g.pickColor("blue"))
}
}
func (g *RogueGame) readScareMonster(*Object) {
// Reading it is a mistake and produces laughter at her poor boo
// boo.
g.msg("you hear maniacal laughter in the distance")
}
func (g *RogueGame) readRemoveCurse(*Object) {
p := &g.Player
uncurse(p.CurArmor)
uncurse(p.CurWeapon)
uncurse(p.CurRing[Left])
uncurse(p.CurRing[Right])
g.msg("%s", g.chooseStr("you feel in touch with the Universal Onenes",
"you feel as if somebody is watching over you"))
}
func (g *RogueGame) readAggravateMonsters(*Object) {
// This scroll aggravates all the monsters on the current level
// and sets them running towards the hero
g.aggravate()
g.msg("you hear a high pitched humming noise")
}
func (g *RogueGame) readProtectArmor(*Object) {
p := &g.Player
if p.CurArmor != nil {
p.CurArmor.Flags.Set(Protected)
g.msg("your armor is covered by a shimmering %s shield",
g.pickColor("gold"))
} else {
g.msg("you feel a strange sense of loss")
}
}
// uncurse uncurses an item (scrolls.c uncurse).
func uncurse(obj *Object) {
if obj != nil {
obj.Flags.Clear(Cursed)
}
}

101
game/seedcompat_test.go Normal file
View File

@@ -0,0 +1,101 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"fmt"
"os"
"strings"
"testing"
)
// dumpItemTables formats a game's per-seed item appearance tables in the
// same layout the instrumented C reference prints: the potion colors,
// scroll names, ring stones, and wand/staff materials, each generated by
// consuming the RNG in a fixed order during New().
func dumpItemTables(seed int32, g *RogueGame) string {
var b strings.Builder
fmt.Fprintf(&b, "SEED %d\n", seed)
fmt.Fprintln(&b, "POTIONS")
for _, c := range g.Items.PotColors {
fmt.Fprintln(&b, c)
}
fmt.Fprintln(&b, "SCROLLS")
for _, s := range g.Items.ScrNames {
fmt.Fprintln(&b, s)
}
fmt.Fprintln(&b, "RINGS")
for _, s := range g.Items.RingStones {
fmt.Fprintln(&b, s)
}
fmt.Fprintln(&b, "STICKS")
for i := range g.Items.WandType {
fmt.Fprintf(&b, "%s %s\n", g.Items.WandType[i], g.Items.WandMade[i])
}
return b.String()
}
// TestSeedCompatItemTables proves the port's seed-compatibility claim: for
// the same seed, the Go game generates the exact per-seed item appearance
// tables as the C reference on modern-rogue. That requires the LCG and its
// consumption order through the whole init sequence (init_probs →
// init_player → init_names → init_colors → init_stones → init_materials) to
// match C byte for byte. The golden is captured from an instrumented build
// of the C game (testdata/README.md).
func TestSeedCompatItemTables(t *testing.T) {
t.Parallel()
golden, err := os.ReadFile("testdata/item_tables.golden")
if err != nil {
t.Fatalf("read golden: %v", err)
}
// These must match the seeds the golden was generated from
// (testdata/README.md).
seeds := []int32{1, 42, 12345, 99999}
var got strings.Builder
for _, seed := range seeds {
g := New(Params{Seed: seed, Wizard: true})
got.WriteString(dumpItemTables(seed, g))
}
if got.String() != string(golden) {
t.Errorf("Go item tables diverge from the C reference at %s",
firstDiff(string(golden), got.String()))
}
}
// firstDiff returns a description of the first line where want and got
// differ, for a readable failure.
func firstDiff(want, got string) string {
wl := strings.Split(want, "\n")
gl := strings.Split(got, "\n")
for i := 0; i < len(wl) || i < len(gl); i++ {
w, g := "", ""
if i < len(wl) {
w = wl[i]
}
if i < len(gl) {
g = gl[i]
}
if w != g {
return fmt.Sprintf("line %d: C=%q Go=%q", i+1, w, g)
}
}
return "no line difference (trailing content?)"
}

558
game/sticks.go Normal file
View File

@@ -0,0 +1,558 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import "fmt"
// sticks.c — zap wands and staffs.
// The two ws_type strings a stick can be made as.
const (
wandName = "wand"
staffName = "staff"
)
// doZap performs a zap with a wand (sticks.c do_zap).
func (g *RogueGame) doZap() {
obj, ok := g.promptPackItem("zap with", KindWand)
if !ok {
return
}
if obj.Kind != KindWand {
g.After = false
g.msg("you can't zap with that!")
return
}
if obj.Charges == 0 {
g.msg("nothing happens")
return
}
// 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--
}
// zapRayMonster walks the zap ray from the hero to the first blocking
// spot and returns the monster standing there, if any (the shared
// preamble of the C monster-affecting zap cases).
func (g *RogueGame) zapRayMonster() *Monster {
p := &g.Player
y := p.Pos.Y
x := p.Pos.X
for stepOk(g.Level.VisibleChar(y, x)) {
y += g.Delta.Y
x += g.Delta.X
}
return g.Level.MonsterAt(y, x)
}
// zapVictim is zapRayMonster plus the flytrap release the C code does
// before the invisibility-family effects.
func (g *RogueGame) zapVictim() *Monster {
tp := g.zapRayMonster()
if tp != nil && tp.Type == 'F' {
g.Player.Flags.Clear(Held)
}
return tp
}
// The per-wand effect handlers, dispatched through gameData.zapHandlers.
// Each is one case of the C do_zap switch; returning false aborts the
// zap without using a charge.
func (g *RogueGame) zapLight(*Object) bool {
// Reddy Kilowatt wand. Light up the room
p := &g.Player
g.Items.Sticks[WandLight].Know = true
if p.Room.Flags.Has(Gone) {
g.msg("the corridor glows and then fades")
} else {
p.Room.Flags.Clear(Dark)
// Light the room and put the player back up
g.enterRoom(p.Pos)
g.addmsgf("the room is lit")
if !g.Options.Terse {
g.addmsgf(" by a shimmering %s light", g.pickColor("blue"))
}
g.endmsg()
}
return true
}
func (g *RogueGame) zapDrainLife(*Object) bool {
// take away 1/2 of hero's hit points, then take it away evenly
// from the monsters in the room (or next to hero if he is in a
// passage)
if g.Player.Stats.HP < 2 {
g.msg("you are too weak to use it")
return false
}
g.drain()
return true
}
func (g *RogueGame) zapInvisibility(*Object) bool {
if tp := g.zapVictim(); tp != nil {
tp.Flags.Set(Invisible)
if g.canSee(tp.Pos.Y, tp.Pos.X) {
g.mvaddch(tp.Pos.Y, tp.Pos.X, tp.OldCh)
}
}
return true
}
func (g *RogueGame) zapPolymorph(*Object) bool {
tp := g.zapVictim()
if tp == nil {
return true
}
y, x := tp.Pos.Y, tp.Pos.X
pp := tp.Pack
g.Level.RemoveMonster(tp)
if g.seeMonst(tp) {
g.mvaddch(y, x, g.Level.Char(y, x))
}
oldch := tp.OldCh
g.Delta.Y = y
g.Delta.X = x
monster := g.randomMonsterLetter()
g.newMonster(tp, monster, g.Delta)
if g.seeMonst(tp) {
g.mvaddch(y, x, monster)
}
tp.OldCh = oldch
tp.Pack = pp
if g.seeMonst(tp) {
g.Items.Sticks[WandPolymorph].Know = true
}
return true
}
func (g *RogueGame) zapCancellation(*Object) bool {
if tp := g.zapVictim(); tp != nil {
tp.Flags.Set(Cancelled)
tp.Flags.Clear(Invisible | CanConfuse)
tp.Disguise = tp.Type
if g.seeMonst(tp) {
g.mvaddch(tp.Pos.Y, tp.Pos.X, tp.Disguise)
}
}
return true
}
func (g *RogueGame) zapTeleport(obj *Object) bool {
p := &g.Player
tp := g.zapVictim()
if tp == nil {
return true
}
var newPos Coord
if obj.WandKind() == WandTeleportAway {
for {
newPos, _ = g.findFloor(true)
if newPos != p.Pos {
break
}
}
} else {
newPos.Y = p.Pos.Y + g.Delta.Y
newPos.X = p.Pos.X + g.Delta.X
}
tp.Dest = &p.Pos
tp.Flags.Set(Awake)
g.relocate(tp, newPos)
return true
}
func (g *RogueGame) zapMagicMissile(*Object) bool {
p := &g.Player
g.Items.Sticks[WandMagicMissile].Know = true
bolt := newObject()
bolt.Kind = KindGold // C set o_type='*': draws a '*' and is not a weapon
bolt.HurlDmg = dice("1x4")
bolt.HPlus = 100
bolt.DPlus = 1
bolt.Flags = Missile
if p.CurWeapon != nil {
bolt.Launch = WeaponKind(p.CurWeapon.Which)
}
g.doMotion(bolt, g.Delta.Y, g.Delta.X)
if tp := g.Level.MonsterAt(bolt.Pos.Y, bolt.Pos.X); tp != nil &&
!g.saveThrow(VsMagic, &tp.Stats) {
g.hitMonster(bolt.Pos, bolt)
} else if g.Options.Terse {
g.msg("missle vanishes") //nolint:misspell // C's spelling, kept faithfully
} else {
g.msg("the missle vanishes with a puff of smoke") //nolint:misspell // C's spelling
}
return true
}
func (g *RogueGame) zapSpeed(obj *Object) bool {
tp := g.zapRayMonster()
if tp == nil {
return true
}
if obj.WandKind() == WandHasteMonster {
hasteTarget(tp)
} else {
slowTarget(tp)
}
g.Delta.Y = tp.Pos.Y
g.Delta.X = tp.Pos.X
g.runTo(g.Delta)
return true
}
// hasteTarget cancels a slow or applies a haste (the WS_HASTE_M arm of
// do_zap).
func hasteTarget(tp *Monster) {
if tp.On(Slowed) {
tp.Flags.Clear(Slowed)
} else {
tp.Flags.Set(Hasted)
}
}
// slowTarget cancels a haste or applies a slow (the WS_SLOW_M arm of
// do_zap).
func slowTarget(tp *Monster) {
if tp.On(Hasted) {
tp.Flags.Clear(Hasted)
} else {
tp.Flags.Set(Slowed)
}
tp.Turn = true
}
func (g *RogueGame) zapBolt(obj *Object) bool {
var name string
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.WandKind() {
case WandLightning:
name = "bolt"
case WandFire:
name = "flame"
default:
name = "ice"
}
g.fireBolt(g.Player.Pos, &g.Delta, name)
g.Items.Sticks[obj.Which].Know = true
return true
}
// drain does the drain-hit-points-from-player schtick (sticks.c drain).
func (g *RogueGame) drain() {
p := &g.Player
// First count how many things we need to spread the hit points among
var corp *Room
if g.Level.Char(p.Pos.Y, p.Pos.X) == Door {
corp = &g.Level.Passages[*g.Level.FlagsAt(p.Pos.Y, p.Pos.X)&FPassNum]
}
inpass := p.Room.Flags.Has(Gone)
var drainee []*Monster
for _, mp := range g.Level.Monsters {
if g.drainReaches(mp, corp, inpass) {
drainee = append(drainee, mp)
}
}
cnt := len(drainee)
if cnt == 0 {
g.msg("you have a tingling feeling")
return
}
p.Stats.HP /= 2
cnt = p.Stats.HP / cnt
// Now zot all of the monsters
for _, mp := range drainee {
if mp.Stats.HP -= cnt; mp.Stats.HP <= 0 {
g.killed(mp, g.seeMonst(mp))
} else {
g.runTo(mp.Pos)
}
}
}
// drainReaches reports whether the drain-life wand reaches this monster:
// the hero's room, the passage behind the door he stands on, or — when
// he is in a passage — a door of that same passage (the drainee
// condition of sticks.c drain).
func (g *RogueGame) drainReaches(mp *Monster, corp *Room, inpass bool) bool {
p := &g.Player
return mp.Room == p.Room || mp.Room == corp ||
(inpass && g.Level.Char(mp.Pos.Y, mp.Pos.X) == Door &&
&g.Level.Passages[*g.Level.FlagsAt(mp.Pos.Y, mp.Pos.X)&FPassNum] == p.Room)
}
// fireBolt fires a bolt in a given direction from a specific starting
// place (sticks.c fire_bolt).
func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) {
p := &g.Player
fromHero := start == p.Pos
bolt := newObject()
bolt.Kind = KindWeapon
bolt.Which = int(WeaponFlame)
bolt.HurlDmg = dice("6x6")
bolt.HPlus = 100
bolt.DPlus = 0
g.Items.Weapons[WeaponFlame].Name = name
dirch := boltDirChar(*dir)
pos := start
hitHero := !fromHero
used := false
changed := false
var spotpos []Coord
for len(spotpos) < BoltLength && !used {
pos.Y += dir.Y
pos.X += dir.X
spotpos = append(spotpos, pos)
ch := g.Level.VisibleChar(pos.Y, pos.X)
if boltBounces(ch, p.Pos, pos) {
if !changed {
hitHero = !hitHero
}
changed = false
dir.Y = -dir.Y
dir.X = -dir.X
spotpos = spotpos[:len(spotpos)-1]
g.msg("the %s bounces", name)
continue
}
if tp := g.Level.MonsterAt(pos.Y, pos.X); !hitHero && tp != nil {
hitHero = true
changed = !changed
used = g.boltStrikesMonster(tp, bolt, pos, ch, name, fromHero)
} else if hitHero && pos == p.Pos {
hitHero = false
changed = !changed
used = g.boltStrikesHero(start, name, fromHero)
}
g.mvaddch(pos.Y, pos.X, dirch)
g.refresh()
}
// erase the bolt trail
for _, c2 := range spotpos {
g.mvaddch(c2.Y, c2.X, g.Level.Char(c2.Y, c2.X))
}
}
// boltDirChar picks the character a traveling bolt is drawn with for its
// direction (the dirch switch of sticks.c fire_bolt).
func boltDirChar(dir Coord) byte {
switch dir.Y + dir.X {
case 0:
return '/'
case 1, -1:
if dir.Y == 0 {
return '-'
}
return '|'
case 2, -2:
return '\\'
}
return 0 // unreachable for the eight legal directions, as in C
}
// boltBounces reports whether a bolt bounces off this spot: walls, and
// any door except the one the hero stands on (which would otherwise loop
// infinitely, per the C comment in fire_bolt).
func boltBounces(ch byte, heroPos, pos Coord) bool {
switch ch {
case Door:
return heroPos != pos
case '|', '-', ' ':
return true
}
return false
}
// boltStrikesMonster resolves a bolt arriving on a monster's square (the
// monster arm of the fire_bolt loop). It reports whether the bolt was
// used up.
func (g *RogueGame) boltStrikesMonster(
tp *Monster, bolt *Object, pos Coord, ch byte, name string, fromHero bool,
) bool {
tp.OldCh = g.Level.Char(pos.Y, pos.X)
if !g.saveThrow(VsMagic, &tp.Stats) {
bolt.Pos = pos
if tp.Type == 'D' && name == "flame" {
g.addmsgf("the flame bounces")
if !g.Options.Terse {
g.addmsgf(" off the dragon")
}
g.endmsg()
} else {
g.hitMonster(pos, bolt)
}
return true
}
if ch != 'M' || tp.Disguise == 'M' {
if fromHero {
g.runTo(pos)
}
if g.Options.Terse {
g.msg("%s misses", name)
} else {
g.msg("the %s whizzes past %s", name, g.setMname(tp))
}
}
return false
}
// boltStrikesHero resolves a bolt arriving on the hero (the hero arm of
// the fire_bolt loop). It reports whether the bolt was used up.
func (g *RogueGame) boltStrikesHero(start Coord, name string, fromHero bool) bool {
p := &g.Player
if g.save(VsMagic) {
g.msg("the %s whizzes by you", name)
return false
}
if p.Stats.HP -= g.roll(6, 6); p.Stats.HP <= 0 {
if fromHero {
g.death('b')
} else {
g.death(g.Level.MonsterAt(start.Y, start.X).Type)
}
}
if g.Options.Terse {
g.msg("the %s hits", name)
} else {
g.msg("you are hit by the %s", name)
}
return true
}
// fixStick sets up a new wand or staff (sticks.c fix_stick).
func (g *RogueGame) fixStick(cur *Object) {
// ws_type[] is indexed by Which; a malformed one is treated as a wand,
// which is the branch the C string compare would take against any
// value that is not literally "staff". The charge switch below already
// funnels everything but WandLight into its default arm.
if cur.hasValidWhich() && g.Items.WandType[cur.Which] == staffName {
cur.Damage = dice("2x3")
} else {
cur.Damage = dice("1x1")
}
cur.HurlDmg = dice("1x1")
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch cur.WandKind() {
case WandLight:
cur.Charges = g.rnd(10) + 10
default:
cur.Charges = g.rnd(5) + 3
}
}
// chargeStr returns the charge-count suffix for an identified stick
// (sticks.c charge_str).
func chargeStr(g *RogueGame, obj *Object) string {
if !obj.Flags.Has(Known) {
return ""
}
if g.Options.Terse {
return fmt.Sprintf(" [%d]", obj.Charges)
}
return fmt.Sprintf(" [%d charges]", obj.Charges)
}

874
game/sticks_test.go Normal file
View File

@@ -0,0 +1,874 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"slices"
"strings"
"testing"
)
// The zap and bolt tests need a map they can reason about: real levels
// put their rooms wherever rooms.c felt like, and sticks.c's geometry —
// which square a bolt bounces off, which room a drain reaches — only
// means anything against known walls, a known door, and a known passage
// number. mkCarvedGame lays out two rooms joined by one corridor, using
// the generator's own drawRoom so the wall characters (including the
// '-' corners horiz() paints over vert()'s '|') are what a real level
// would have:
//
// x: 1 20 40 59
// y=1 -------------------- ------------------
// |..................| |................|
// y=4 |..................+########+................|
// |..................| |................|
// y=8 -------------------- ------------------
const (
carvedWidth = 20 // room width, both walls included
carvedHeight = 8 // room height, both walls included
roomAX = 1 // left wall of the west room
roomBX = 40 // left wall of the east room
carvedTopY = 1 // top wall of both rooms
corridorY = 4 // row the corridor and doors run on
doorAX = roomAX + carvedWidth - 1 // east wall of the west room
carvedPass = 2 // passage number of the corridor
)
// saveProofLvl makes save_throw(VS_MAGIC) succeed on every roll, so a
// test can select the "it saved" arm without touching the RNG: C's
// threshold is 14 + VS_MAGIC - lvl/2, which at level 40 is -3, and
// roll(1,20) always clears that.
const saveProofLvl = 40
// mkCarvedGame builds a game on the hand-carved level drawn above, with
// the hero standing in the south-east corner of the west room — off
// every row and column the bolt tests fire along.
func mkCarvedGame(t *testing.T, seed int32) *RogueGame {
t.Helper()
g := New(Params{Seed: seed, Term: &testTerm{}})
for i := range g.Level.Places {
g.Level.Places[i] = Place{Ch: ' ', Flags: FReal}
}
for i, x := range [...]int{roomAX, roomBX} {
rp := &g.Level.Rooms[i]
*rp = Room{
Pos: Coord{X: x, Y: carvedTopY},
Max: Coord{X: carvedWidth, Y: carvedHeight},
}
g.drawRoom(rp)
}
for i := 2; i < MaxRooms; i++ {
g.Level.Rooms[i].Flags = Gone // rooms that are not there
}
for x := doorAX + 1; x < roomBX; x++ {
pp := g.Level.At(corridorY, x)
pp.Ch = Passage
pp.Flags = FReal | FPassage | carvedPass
}
// Doors carry the passage number in their low bits but not F_PASS,
// exactly as passages.c numpass leaves them; roomin therefore reports
// the room a door belongs to, and drain's corp lookup finds the
// passage behind it.
for _, x := range [...]int{doorAX, roomBX} {
pp := g.Level.At(corridorY, x)
pp.Ch = Door
pp.Flags = FReal | carvedPass
}
placeHero(g, Coord{X: roomAX + 17, Y: carvedTopY + 6})
return g
}
// placeHero moves the hero and keeps proom, oldpos and oldrp in step,
// the way move.c and misc.c look do; a --More-- prompt redraws through
// look, which reads all three.
func placeHero(g *RogueGame, pos Coord) {
g.Player.Pos = pos
g.Player.Room = g.roomIn(pos)
g.Oldpos = pos
g.Oldrp = g.Player.Room
}
// putMonster drops a monster of the given letter on a carved-level spot.
func putMonster(g *RogueGame, typ byte, pos Coord) *Monster {
tp := &Monster{}
g.newMonster(tp, typ, pos)
return tp
}
// pinRng rewinds the generator to a state whose next draw is exactly
// want, so tests can choose a save-throw outcome or a polymorph letter
// without assuming anything about the generator itself: the wanted
// value is found by running the real Rng, not by predicting it.
func pinRng(t *testing.T, g *RogueGame, draw func(*Rng) int, want int) {
t.Helper()
for s := int32(1); s < 100000; s++ {
probe := Rng{Seed: s}
if draw(&probe) == want {
g.Rng.Seed = s
return
}
}
t.Fatalf("no seed found whose next draw is %d", want)
}
// d20 is the save_throw draw (monsters.c save_throw: roll(1, 20)).
func d20(r *Rng) int { return r.Roll(1, 20) }
// zapWand builds a wand of the given kind with charges to spare.
func zapWand(kind WandKind) *Object {
obj := newObject()
obj.Kind = KindWand
obj.Which = int(kind)
obj.Charges = 5
return obj
}
// TestZapLightLightsTheRoom covers the WS_LIGHT arm: the room loses
// ISDARK, the wand identifies itself, and the message is C's two-part
// one (sticks.c 71-89).
func TestZapLightLightsTheRoom(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 11)
g.Player.Room.Flags.Set(Dark)
g.zapLight(zapWand(WandLight))
if g.Player.Room.Flags.Has(Dark) {
t.Error("the room is still dark after a wand of light")
}
if !g.Items.Sticks[WandLight].Know {
t.Error("the wand of light did not identify itself")
}
const want = "the room is lit by a shimmering blue light"
if g.Msgs.Huh != want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
}
}
// TestZapLightInPassageFades covers the ISGONE arm: a corridor is not a
// room, so nothing is lit and the wand still becomes known.
func TestZapLightInPassageFades(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 12)
placeHero(g, Coord{X: doorAX + 3, Y: corridorY})
g.Level.Rooms[0].Flags.Set(Dark)
g.zapLight(zapWand(WandLight))
if !g.Player.Room.Flags.Has(Gone) {
t.Fatal("the hero is not in a passage; the test set-up is wrong")
}
const want = "the corridor glows and then fades"
if g.Msgs.Huh != want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
}
if !g.Items.Sticks[WandLight].Know {
t.Error("the wand of light did not identify itself in a corridor")
}
if !g.Level.Rooms[0].Flags.Has(Dark) {
t.Error("zapping in a corridor lit a room anyway")
}
}
// TestZapDrainLifeTooWeakKeepsCharge covers C's early return: under two
// hit points the zap is refused, and because C returns before the
// switch falls out, o_charges-- never runs.
func TestZapDrainLifeTooWeakKeepsCharge(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 13)
g.Player.Stats.HP = 1
wand := zapWand(WandDrainLife)
ch := give(g, wand)
setInput(t, g, ch)
g.doZap()
const want = "you are too weak to use it"
if g.Msgs.Huh != want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
}
if wand.Charges != 5 {
t.Errorf("charges = %d, want 5: the refused zap must not cost one",
wand.Charges)
}
if g.Player.Stats.HP != 1 {
t.Errorf("hit points = %d, want 1", g.Player.Stats.HP)
}
}
// TestDrainSplitsHitPoints covers sticks.c drain: the hero loses half
// his hit points and the drainees each lose that half divided by their
// number — monsters out of reach lose nothing.
func TestDrainSplitsHitPoints(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 14)
placeHero(g, Coord{X: 5, Y: 3})
g.Player.Stats.HP = 20
near := [2]*Monster{
putMonster(g, 'Z', Coord{X: 7, Y: 3}),
putMonster(g, 'Z', Coord{X: 9, Y: 5}),
}
far := putMonster(g, 'Z', Coord{X: roomBX + 5, Y: 3})
for _, tp := range []*Monster{near[0], near[1], far} {
tp.Stats.HP = 100
}
g.drain()
if g.Player.Stats.HP != 10 {
t.Errorf("hero hit points = %d, want 10", g.Player.Stats.HP)
}
// 10 hit points spread over two drainees is 5 apiece.
for i, tp := range near {
if tp.Stats.HP != 95 {
t.Errorf("drainee %d hit points = %d, want 95", i, tp.Stats.HP)
}
}
if far.Stats.HP != 100 {
t.Errorf("the monster in the other room lost %d hit points",
100-far.Stats.HP)
}
}
// TestDrainWithNoTargetsCostsNothing covers the cnt == 0 arm, which
// returns before pstats.s_hpt is halved.
func TestDrainWithNoTargetsCostsNothing(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 15)
placeHero(g, Coord{X: 5, Y: 3})
g.Player.Stats.HP = 20
g.drain()
const want = "you have a tingling feeling"
if g.Msgs.Huh != want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
}
if g.Player.Stats.HP != 20 {
t.Errorf("hero hit points = %d, want 20: a drain that found nobody "+
"returns before halving them", g.Player.Stats.HP)
}
}
// TestDrainKillsWeakMonster covers the other arm of drain's zot loop: a
// drainee whose share of the hit points finishes it is killed outright.
func TestDrainKillsWeakMonster(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 29)
placeHero(g, Coord{X: 5, Y: 3})
g.Player.Stats.HP = 20
tp := putMonster(g, 'Z', Coord{X: 7, Y: 3})
tp.Stats.HP = 3 // less than the ten points it is about to take
g.drain()
if len(g.Level.Monsters) != 0 {
t.Error("the drained monster is still on the level")
}
if g.Level.MonsterAt(7, 3) != nil {
t.Error("the drained monster is still on the map")
}
const want = "you have defeated the zombie"
if g.Msgs.Huh != want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
}
}
// TestZapSpeedTogglesHasteAndSlow covers both WS_HASTE_M and WS_SLOW_M
// in both directions: C cancels the opposite condition when it is
// already on, and only otherwise applies its own.
func TestZapSpeedTogglesHasteAndSlow(t *testing.T) {
t.Parallel()
tests := []struct {
name string
kind WandKind
start CreatureFlags
wantHasted bool
wantSlowed bool
wantTurn bool
}{
{name: "haste a monster", kind: WandHasteMonster, wantHasted: true},
{
name: "haste cancels a slow",
kind: WandHasteMonster,
start: Slowed,
},
{
name: "slow a monster",
kind: WandSlowMonster,
wantSlowed: true,
wantTurn: true,
},
{
name: "slow cancels a haste",
kind: WandSlowMonster,
start: Hasted,
wantTurn: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 30)
placeHero(g, Coord{X: 5, Y: corridorY})
g.Delta = Coord{X: 1, Y: 0}
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
tp.Flags.Clear(Hasted | Slowed)
tp.Flags.Set(tt.start)
tp.Turn = false // only the slow arm sets t_turn
g.zapSpeed(zapWand(tt.kind))
if tp.On(Hasted) != tt.wantHasted {
t.Errorf("hasted = %v, want %v", tp.On(Hasted), tt.wantHasted)
}
if tp.On(Slowed) != tt.wantSlowed {
t.Errorf("slowed = %v, want %v", tp.On(Slowed), tt.wantSlowed)
}
if tp.Turn != tt.wantTurn {
t.Errorf("turn = %v, want %v", tp.Turn, tt.wantTurn)
}
if !tp.On(Awake) {
t.Error("the zapped monster was not set running")
}
})
}
}
// TestDrainReaches pins the three-clause drainee test of sticks.c drain
// one clause at a time: the hero's own room, the passage behind the door
// he stands on (corp), and — only when he is in a passage — a door of
// that same passage.
func TestDrainReaches(t *testing.T) {
t.Parallel()
tests := []struct {
name string
heroPos Coord
monstPos Coord
want bool
}{
{
name: "same room",
heroPos: Coord{X: 5, Y: 3},
monstPos: Coord{X: 9, Y: 6},
want: true,
},
{
name: "different room",
heroPos: Coord{X: 5, Y: 3},
monstPos: Coord{X: roomBX + 5, Y: 3},
want: false,
},
{
name: "hero on a door reaches into that passage",
heroPos: Coord{X: doorAX, Y: corridorY},
monstPos: Coord{X: doorAX + 4, Y: corridorY},
want: true,
},
{
name: "hero in the passage reaches its doors",
heroPos: Coord{X: doorAX + 4, Y: corridorY},
monstPos: Coord{X: roomBX, Y: corridorY},
want: true,
},
{
name: "hero in the passage does not reach into a room",
heroPos: Coord{X: doorAX + 4, Y: corridorY},
monstPos: Coord{X: roomBX + 5, Y: 3},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 16)
placeHero(g, tt.heroPos)
tp := putMonster(g, 'Z', tt.monstPos)
var corp *Room
if g.Level.Char(tt.heroPos.Y, tt.heroPos.X) == Door {
corp = &g.Level.Passages[*g.Level.FlagsAt(
tt.heroPos.Y, tt.heroPos.X)&FPassNum]
}
inpass := g.Player.Room.Flags.Has(Gone)
if got := g.drainReaches(tp, corp, inpass); got != tt.want {
t.Errorf("drainReaches = %v, want %v (inpass=%v corp=%v)",
got, tt.want, inpass, corp != nil)
}
})
}
}
// TestZapInvisibilityHidesMonster covers the WS_INVIS arm.
func TestZapInvisibilityHidesMonster(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 17)
placeHero(g, Coord{X: 5, Y: corridorY})
g.Delta = Coord{X: 1, Y: 0}
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
g.zapInvisibility(zapWand(WandInvisibility))
if !tp.On(Invisible) {
t.Error("the zapped monster is still visible")
}
}
// TestZapVictimReleasesFlytrap covers the shared preamble of C's
// invisibility family: the flytrap holding the hero lets go the moment
// the ray reaches it, whichever of those wands was zapped.
func TestZapVictimReleasesFlytrap(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 18)
placeHero(g, Coord{X: 5, Y: corridorY})
g.Delta = Coord{X: 1, Y: 0}
g.Player.Flags.Set(Held)
tp := putMonster(g, 'F', Coord{X: 6, Y: corridorY})
g.zapInvisibility(zapWand(WandInvisibility))
if g.Player.On(Held) {
t.Error("the flytrap still holds the hero after the zap")
}
if !tp.On(Invisible) {
t.Error("the flytrap was not made invisible")
}
}
// TestZapPolymorphReplacesMonster covers the WS_POLYMORPH arm and its
// detach/re-attach dance: the creature keeps its identity (the same
// THING, its pack, and the character it is standing on) but becomes a
// different monster, listed once and standing where it stood.
func TestZapPolymorphReplacesMonster(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 19)
placeHero(g, Coord{X: 5, Y: corridorY})
g.Delta = Coord{X: 1, Y: 0}
pos := Coord{X: 8, Y: corridorY}
tp := putMonster(g, 'K', pos)
loot := newObject()
loot.Kind = KindPotion
tp.Pack = []*Object{loot}
tp.OldCh = Stairs // it is standing on the staircase
const want = 'T'
pinRng(t, g, func(r *Rng) int { return r.Rnd(26) }, int(want-'A'))
g.zapPolymorph(zapWand(WandPolymorph))
if tp.Type != want || tp.Disguise != want {
t.Errorf("monster is %q/%q after polymorph, want %q",
tp.Type, tp.Disguise, want)
}
if tp.Stats.Lvl != g.Monsters[want-'A'].Stats.Lvl {
t.Errorf("level = %d, want the troll's %d: new_monster did not "+
"re-roll the stats", tp.Stats.Lvl, g.Monsters[want-'A'].Stats.Lvl)
}
if len(tp.Pack) != 1 || tp.Pack[0] != loot {
t.Error("polymorph lost the monster's pack")
}
if tp.OldCh != Stairs {
t.Errorf("under-character = %q, want %q", tp.OldCh, Stairs)
}
if g.Level.MonsterAt(pos.Y, pos.X) != tp || tp.Pos != pos {
t.Error("the polymorphed monster is not where it stood")
}
if n := len(g.Level.Monsters); n != 1 {
t.Errorf("monster list holds %d entries, want 1: detach and "+
"new_monster's attach must balance", n)
}
if !g.Items.Sticks[WandPolymorph].Know {
t.Error("a polymorph the hero watched did not identify the wand")
}
}
// TestZapPolymorphClobbersDelta pins a C quirk the port keeps: do_zap
// reuses the global delta as scratch for new_monster's coordinate, so
// the zap direction is gone by the time the arm returns.
func TestZapPolymorphClobbersDelta(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 20)
placeHero(g, Coord{X: 5, Y: corridorY})
g.Delta = Coord{X: 1, Y: 0}
pos := Coord{X: 8, Y: corridorY}
putMonster(g, 'K', pos)
g.zapPolymorph(zapWand(WandPolymorph))
if g.Delta != pos {
t.Errorf("delta = %v after polymorph, want the victim's %v",
g.Delta, pos)
}
}
// TestZapCancellationClearsSpecials covers the WS_CANCEL arm. CANHUH is
// set on the player and never on a monster in C (only scrolls.c sets
// it), so the test puts it on by hand: the clear is written to take both
// bits and the port must keep doing so.
func TestZapCancellationClearsSpecials(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 21)
placeHero(g, Coord{X: 5, Y: corridorY})
g.Delta = Coord{X: 1, Y: 0}
tp := putMonster(g, 'M', Coord{X: 8, Y: corridorY})
tp.Flags.Set(Invisible | CanConfuse)
g.zapCancellation(zapWand(WandCancellation))
if !tp.On(Cancelled) {
t.Error("the monster was not cancelled")
}
if tp.On(Invisible) {
t.Error("cancellation left the monster invisible")
}
if tp.On(CanConfuse) {
t.Error("cancellation left the monster able to confuse")
}
// t_disguise = t_type is an identity for every monster a zap ray can
// actually stop on: the one disguised kind, the xeroc, looks like an
// item, and step_ok is true for item characters, so the ray walks
// straight past it. Pinned anyway, because C assigns it.
if tp.Disguise != tp.Type {
t.Errorf("disguise = %q, want %q", tp.Disguise, tp.Type)
}
}
// TestZapTeleportToPullsMonsterIn covers WS_TELTO: the victim lands on
// hero + delta, which is the square next to the hero along the ray.
func TestZapTeleportToPullsMonsterIn(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 22)
hero := Coord{X: 5, Y: corridorY}
placeHero(g, hero)
g.Delta = Coord{X: 1, Y: 0}
from := Coord{X: 8, Y: corridorY}
tp := putMonster(g, 'Z', from)
g.zapTeleport(zapWand(WandTeleportTo))
want := Coord{X: hero.X + 1, Y: hero.Y}
if tp.Pos != want {
t.Errorf("monster at %v after teleport-to, want %v", tp.Pos, want)
}
if g.Level.MonsterAt(want.Y, want.X) != tp {
t.Error("the map does not have the monster at its new spot")
}
if g.Level.MonsterAt(from.Y, from.X) != nil {
t.Error("the monster is still on the map where it came from")
}
if tp.Dest != &g.Player.Pos {
t.Error("the teleported monster is not chasing the hero")
}
if !tp.On(Awake) {
t.Error("the teleported monster was not woken")
}
}
// TestZapTeleportAwayMovesMonsterOff covers WS_TELAWAY, whose C loop
// re-draws until the spot is not the hero's own.
func TestZapTeleportAwayMovesMonsterOff(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 23)
hero := Coord{X: 5, Y: corridorY}
placeHero(g, hero)
g.Delta = Coord{X: 1, Y: 0}
from := Coord{X: 8, Y: corridorY}
tp := putMonster(g, 'Z', from)
g.zapTeleport(zapWand(WandTeleportAway))
if tp.Pos == from {
t.Error("teleport away did not move the monster")
}
if tp.Pos == hero {
t.Error("teleport away dropped the monster onto the hero")
}
if g.Level.Char(tp.Pos.Y, tp.Pos.X) != Floor {
t.Errorf("monster landed on %q, want floor",
g.Level.Char(tp.Pos.Y, tp.Pos.X))
}
if g.Level.MonsterAt(from.Y, from.X) != nil {
t.Error("the monster is still on the map where it came from")
}
}
// vanishMsg is what C says when the missile finds nobody to hit, with
// the original spelling of "missile" intact (sticks.c 191).
//
//nolint:misspell // C's spelling, kept faithfully
const vanishMsg = "the missle vanishes with a puff of smoke"
// TestZapMagicMissile covers WS_MISSILE both ways: a victim that saves
// gets C's puff-of-smoke message and no damage, one that does not is
// hit by a bolt whose o_hplus of 100 cannot miss.
func TestZapMagicMissile(t *testing.T) {
t.Parallel()
tests := []struct {
name string
lvl int
wantMsg bool
}{
{name: "victim saves", lvl: saveProofLvl, wantMsg: true},
{name: "victim is hit", lvl: 1, wantMsg: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 24)
placeHero(g, Coord{X: 5, Y: corridorY})
g.Delta = Coord{X: 1, Y: 0}
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
tp.Stats.Lvl = tt.lvl
tp.Stats.HP = 500
pinRng(t, g, d20, 1) // the lowest save throw there is
g.zapMagicMissile(zapWand(WandMagicMissile))
if got := g.Msgs.Huh == vanishMsg; got != tt.wantMsg {
t.Errorf("message = %q, want vanish = %v", g.Msgs.Huh, tt.wantMsg)
}
if hurt := tp.Stats.HP < 500; hurt == tt.wantMsg {
t.Errorf("hit points = %d, want damage = %v",
tp.Stats.HP, !tt.wantMsg)
}
if !g.Items.Sticks[WandMagicMissile].Know {
t.Error("the magic missile wand did not identify itself")
}
})
}
}
// TestFixStickDamage covers the strcmp against ws_type: a staff swings
// for 2x3, everything else for 1x1, and both hurl for 1x1.
func TestFixStickDamage(t *testing.T) {
t.Parallel()
tests := []struct {
material string
want string
}{
{material: staffName, want: "2x3"},
{material: wandName, want: "1x1"},
}
for _, tt := range tests {
t.Run(tt.material, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 25)
g.Items.WandType[WandCold] = tt.material
cur := newObject()
cur.Kind = KindWand
cur.Which = int(WandCold)
g.fixStick(cur)
if !slices.Equal(cur.Damage, dice(tt.want)) {
t.Errorf("damage = %v, want %v", cur.Damage, tt.want)
}
if !slices.Equal(cur.HurlDmg, dice("1x1")) {
t.Errorf("hurl damage = %v, want 1x1", cur.HurlDmg)
}
})
}
}
// TestFixStickCharges covers the charge switch. C is rnd(10)+10 for the
// wand of light and rnd(5)+3 for everything else, so both ends of both
// ranges must show up over enough draws and nothing outside them ever.
func TestFixStickCharges(t *testing.T) {
t.Parallel()
tests := []struct {
name string
kind WandKind
lo, hi int
}{
{name: "light", kind: WandLight, lo: 10, hi: 19},
{name: "other", kind: WandCold, lo: 3, hi: 7},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 26)
lo, hi := 1<<30, -1
for range 500 {
cur := newObject()
cur.Kind = KindWand
cur.Which = int(tt.kind)
g.fixStick(cur)
lo = min(lo, cur.Charges)
hi = max(hi, cur.Charges)
}
if lo != tt.lo || hi != tt.hi {
t.Errorf("charges ranged over %d..%d, want %d..%d",
lo, hi, tt.lo, tt.hi)
}
})
}
}
// TestChargeStr covers sticks.c charge_str: nothing at all until the
// stick is known, then the terse or verbose bracket.
func TestChargeStr(t *testing.T) {
t.Parallel()
tests := []struct {
name string
known bool
terse bool
want string
}{
{name: "unknown", known: false, terse: false, want: ""},
{name: "unknown and terse", known: false, terse: true, want: ""},
{name: "known", known: true, terse: false, want: " [7 charges]"},
{name: "known and terse", known: true, terse: true, want: " [7]"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 27)
g.Options.Terse = tt.terse
obj := zapWand(WandCold)
obj.Charges = 7
if tt.known {
obj.Flags.Set(Known)
}
if got := chargeStr(g, obj); got != tt.want {
t.Errorf("chargeStr = %q, want %q", got, tt.want)
}
})
}
}
// TestZapBoltNames covers the name each of the three bolt wands fires
// under (sticks.c 225-231), read back out of the weapon table entry
// fire_bolt overwrites and out of the bounce message.
func TestZapBoltNames(t *testing.T) {
t.Parallel()
tests := []struct {
kind WandKind
want string
}{
{kind: WandLightning, want: boltName},
{kind: WandFire, want: flameName},
{kind: WandCold, want: iceName},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 28)
placeHero(g, Coord{X: roomAX + 1, Y: corridorY})
g.Player.Stats.Lvl = saveProofLvl // never hurt by the rebound
g.Delta = Coord{X: -1, Y: 0} // straight at the west wall
g.zapBolt(zapWand(tt.kind))
if got := g.Items.Weapons[WeaponFlame].Name; got != tt.want {
t.Errorf("weapon name = %q, want %q", got, tt.want)
}
if !strings.Contains(g.Msgs.Huh, tt.want) {
t.Errorf("message = %q, want it to name the %q",
g.Msgs.Huh, tt.want)
}
if !g.Items.Sticks[tt.kind].Know {
t.Error("the bolt wand did not identify itself")
}
})
}
}

934
game/tables.go Normal file
View File

@@ -0,0 +1,934 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// tables.go — the static data tables the C game kept as file-scope globals
// (extern.c, init.c, and assorted per-file statics). The port gathers them
// into gameData: every RogueGame carries its own copy, so the package holds
// no package-level state. Per-game mutable copies (the ObjInfo tables,
// whose probabilities are re-summed and whose Know/Guess fields change
// during play) are cloned into RogueGame.Items by NewGame.
// helpEntry is rogue.h struct h_list.
type helpEntry struct {
Ch byte
Desc string
Print bool
}
// gameData is the bundle of static tables, built by newGameData and hung on
// RogueGame as g.data. Nothing in it mutates during play: the one table the
// C code writes to (the venus flytrap damage hack) operates on the game's
// Monsters copy, not on this template.
type gameData struct {
// initStats is the C INIT_STATS: the player's starting statistics.
initStats Stats
// aClass is extern.c a_class[]: armor class for each armor type.
aClass [NumArmorTypes]int
// eLevels is extern.c e_levels[]: experience thresholds per level; the
// zero terminates the table as in C.
eLevels []int
// trName is extern.c tr_name[]: names of the traps.
trName [NumTrapTypes]string
// invTName is extern.c inv_t_name[]: the inventory style names.
invTName []string
// monsterTable is extern.c monsters[26]: all monster kinds, indexed by
// letter - 'A'. Monster strength (XX in C) is always 10; HP (___ in C)
// is rolled from the level at creation time.
monsterTable [26]MonsterKind
// Base ObjInfo tables (extern.c). These are templates: NewGame copies
// them into ItemLore before initProbs converts Prob to cumulative form.
baseThings [NumThings]ObjInfo
baseArmInfo [NumArmorTypes]ObjInfo
basePotInfo [NumPotionTypes]ObjInfo
baseRingInfo [NumRingTypes]ObjInfo
baseScrInfo [NumScrollTypes]ObjInfo
baseWeapInfo [NumWeaponTypes + 1]ObjInfo
baseWsInfo [NumWandTypes]ObjInfo
// rainbow is init.c rainbow[]: the possible potion colors.
rainbow []string
// sylls is init.c sylls[]: syllables for generated scroll names.
sylls []string
// stoneTable is init.c stones[]: ring stones and their worth.
stoneTable []Stone
// woods is init.c wood[]: what staffs are made of.
woods []string
// metals is init.c metal[]: what wands are made of.
metals []string
// helpStr is extern.c helpstr[]: the '?' command help text.
helpStr []helpEntry
// hNames are the strings for hitting; the first four are used when the
// player strikes, the second four for monsters (fight.c h_names).
hNames [8]string
// mNames are the strings for missing (fight.c m_names).
mNames [8]string
// strPlus adjusts hit probabilities due to strength (fight.c str_plus).
strPlus [32]int
// addDam adjusts damage done due to strength (fight.c add_dam).
addDam [32]int
// lvlMons and wandMons list monsters in rough order of vorpalness;
// zero entries in wandMons never wander (monsters.c).
lvlMons [26]byte
wandMons [26]byte
// ringUses is the rings.c ring_eat static uses[] table: how much food
// each ring type uses up per turn (negative = a 1-in-n chance of 1).
ringUses [NumRingTypes]int
// initWeaps is the weapons.c init_dam[] table.
initWeaps [NumWeaponTypes]weaponSetup
// pActions is potions.c p_actions[]. The P_SEEINVIS message is dynamic
// (it names the fruit) and is computed in applyPotionFuse.
pActions [NumPotionTypes]pact
// idType maps identify scrolls to the kind of item they identify
// (scrolls.c static id_type).
idType [ScrollIdentifyRingOrStick + 1]ObjectKind
// rdesConn is the hardcoded 3x3 room adjacency matrix from
// passages.c do_passages.
rdesConn [MaxRooms][MaxRooms]bool
// thingList is misc.c rnd_thing()'s static table.
thingList []byte
// identList is command.c's static ident_list.
identList []helpEntry
// hungerStateName is io.c state_name[].
hungerStateName [4]string
// ripArt is the rip.c rip[] tombstone art.
ripArt []string
// killnameTable is the rip.c nlist[]: special death causes.
killnameTable []helpEntry
// scoreReasons is the rip.c reason[] scoreboard strings.
scoreReasons [4]string
// quaffHandlers dispatches each potion kind to its effect method:
// the cases of the potions.c quaff switch. The bool is trip — was
// the hero hallucinating when the potion went down.
quaffHandlers [NumPotionTypes]func(g *RogueGame, trip bool)
// readHandlers dispatches each scroll kind to its effect method:
// the cases of the scrolls.c read_scroll switch. The handler gets
// the scroll being read (the identify family needs its subtype).
readHandlers [NumScrollTypes]func(g *RogueGame, obj *Object)
// zapHandlers dispatches each wand kind to its effect method: the
// cases of the sticks.c do_zap switch. A false return aborts the
// zap without using a charge (drain life on a too-weak hero).
zapHandlers [NumWandTypes]func(g *RogueGame, obj *Object) bool
// hitHandlers dispatches a monster's special power when its hit
// lands, indexed by monster letter - 'A': the cases of the fight.c
// attack switch. A true return means the monster removed itself.
hitHandlers [26]func(g *RogueGame, mp *Monster, mname string) bool
// commandHandlers dispatches the ordinary command keys: the simple
// cases of the big command.c switch. Keys that re-dispatch (runs,
// fight, repeat, move-on) and wizard keys stay in dispatchKey.
commandHandlers map[byte]func(g *RogueGame)
// trapHandlers dispatches each sprung trap to its effect method:
// the cases of the move.c be_trapped switch.
trapHandlers [NumTrapTypes]func(g *RogueGame, tc Coord)
// daemonHandlers dispatches DaemonIDs to their callbacks: the C
// d_func function pointers (daemons.c / daemon.c).
daemonHandlers [DTurnSee + 1]func(g *RogueGame, arg int)
}
// ripWall is the repeated blank wall line of the tombstone art.
const ripWall = " | |"
// newGameData builds the static tables. Each game gets a fresh copy, which
// keeps the package free of globals.
//
//nolint:funlen,maintidx // a single composite literal holding every C data table
func newGameData() *gameData {
return &gameData{
initStats: Stats{
Str: 16, Exp: 0, Lvl: 1, ArmorClass: 10, HP: 12,
Dmg: dice("1x4"), MaxHP: 12,
},
aClass: [NumArmorTypes]int{
8, // LEATHER
7, // RING_MAIL
7, // STUDDED_LEATHER
6, // SCALE_MAIL
5, // CHAIN_MAIL
4, // SPLINT_MAIL
4, // BANDED_MAIL
3, // PLATE_MAIL
},
eLevels: []int{
10, 20, 40, 80, 160, 320, 640, 1300, 2600, 5200, 13000, 26000,
50000, 100000, 200000, 400000, 800000, 2000000, 4000000, 8000000, 0,
},
trName: [NumTrapTypes]string{
"a trapdoor",
"an arrow trap",
"a sleeping gas trap",
"a beartrap",
"a teleport trap",
"a poison dart trap",
"a rust trap",
"a mysterious trap",
},
invTName: []string{"Overwrite", "Slow", "Clear"},
monsterTable: [26]MonsterKind{
/* Name CARRY FLAGS str exp lvl arm hp dmg */
{"aquator", 0, Mean, Stats{10, 20, 5, 2, 1, dice("0x0/0x0"), 0}},
{"bat", 0, Flying, Stats{10, 1, 1, 3, 1, dice("1x2"), 0}},
{"centaur", 15, 0, Stats{10, 17, 4, 4, 1, dice("1x2/1x5/1x5"), 0}},
{"dragon", 100, Mean, Stats{10, 5000, 10, -1, 1, dice("1x8/1x8/3x10"), 0}},
{"emu", 0, Mean, Stats{10, 2, 1, 7, 1, dice("1x2"), 0}},
{"venus flytrap", 0, Mean, Stats{10, 80, 8, 3, 1, dice("%%%x0"), 0}},
{"griffin", 20, Mean | Flying | Regenerates,
Stats{10, 2000, 13, 2, 1, dice("4x3/3x5"), 0}},
{"hobgoblin", 0, Mean, Stats{10, 3, 1, 5, 1, dice("1x8"), 0}},
{"ice monster", 0, 0, Stats{10, 5, 1, 9, 1, dice("0x0"), 0}},
{"jabberwock", 70, 0, Stats{10, 3000, 15, 6, 1, dice("2x12/2x4"), 0}},
{"kestrel", 0, Mean | Flying, Stats{10, 1, 1, 7, 1, dice("1x4"), 0}},
{"leprechaun", 0, 0, Stats{10, 10, 3, 8, 1, dice("1x1"), 0}},
{"medusa", 40, Mean, Stats{10, 200, 8, 2, 1, dice("3x4/3x4/2x5"), 0}},
{"nymph", 100, 0, Stats{10, 37, 3, 9, 1, dice("0x0"), 0}},
{"orc", 15, Greedy, Stats{10, 5, 1, 6, 1, dice("1x8"), 0}},
{"phantom", 0, Invisible, Stats{10, 120, 8, 3, 1, dice("4x4"), 0}},
{"quagga", 0, Mean, Stats{10, 15, 3, 3, 1, dice("1x5/1x5"), 0}},
{"rattlesnake", 0, Mean, Stats{10, 9, 2, 3, 1, dice("1x6"), 0}},
{"snake", 0, Mean, Stats{10, 2, 1, 5, 1, dice("1x3"), 0}},
{"troll", 50, Regenerates | Mean, Stats{10, 120, 6, 4, 1, dice("1x8/1x8/2x6"), 0}},
{"black unicorn", 0, Mean, Stats{10, 190, 7, -2, 1, dice("1x9/1x9/2x9"), 0}},
{"vampire", 20, Regenerates | Mean, Stats{10, 350, 8, 1, 1, dice("1x10"), 0}},
{"wraith", 0, 0, Stats{10, 55, 5, 4, 1, dice("1x6"), 0}},
{"xeroc", 30, 0, Stats{10, 100, 7, 7, 1, dice("4x4"), 0}},
{"yeti", 30, 0, Stats{10, 50, 4, 6, 1, dice("1x6/1x6"), 0}},
{"zombie", 0, Mean, Stats{10, 6, 2, 8, 1, dice("1x8"), 0}},
},
baseThings: [NumThings]ObjInfo{
{Prob: 26}, // potion
{Prob: 36}, // scroll
{Prob: 16}, // food
{Prob: 7}, // weapon
{Prob: 7}, // armor
{Prob: 4}, // ring
{Prob: 4}, // stick
},
baseArmInfo: [NumArmorTypes]ObjInfo{
{Name: "leather armor", Prob: 20, Worth: 20},
{Name: "ring mail", Prob: 15, Worth: 25},
{Name: "studded leather armor", Prob: 15, Worth: 20},
{Name: "scale mail", Prob: 13, Worth: 30},
{Name: "chain mail", Prob: 12, Worth: 75},
{Name: "splint mail", Prob: 10, Worth: 80},
{Name: "banded mail", Prob: 10, Worth: 90},
{Name: "plate mail", Prob: 5, Worth: 150},
},
//nolint:dupl // distinct C data tables that merely share their shape
basePotInfo: [NumPotionTypes]ObjInfo{
{Name: "confusion", Prob: 7, Worth: 5},
{Name: "hallucination", Prob: 8, Worth: 5},
{Name: "poison", Prob: 8, Worth: 5},
{Name: "gain strength", Prob: 13, Worth: 150},
{Name: "see invisible", Prob: 3, Worth: 100},
{Name: "healing", Prob: 13, Worth: 130},
{Name: "monster detection", Prob: 6, Worth: 130},
{Name: "magic detection", Prob: 6, Worth: 105},
{Name: "raise level", Prob: 2, Worth: 250},
{Name: "extra healing", Prob: 5, Worth: 200},
{Name: "haste self", Prob: 5, Worth: 190},
{Name: "restore strength", Prob: 13, Worth: 130},
{Name: "blindness", Prob: 5, Worth: 5},
{Name: "levitation", Prob: 6, Worth: 75},
},
//nolint:dupl // distinct C data tables that merely share their shape
baseRingInfo: [NumRingTypes]ObjInfo{
{Name: "protection", Prob: 9, Worth: 400},
{Name: "add strength", Prob: 9, Worth: 400},
{Name: "sustain strength", Prob: 5, Worth: 280},
{Name: "searching", Prob: 10, Worth: 420},
{Name: "see invisible", Prob: 10, Worth: 310},
{Name: "adornment", Prob: 1, Worth: 10},
{Name: "aggravate monster", Prob: 10, Worth: 10},
{Name: "dexterity", Prob: 8, Worth: 440},
{Name: "increase damage", Prob: 8, Worth: 400},
{Name: "regeneration", Prob: 4, Worth: 460},
{Name: "slow digestion", Prob: 9, Worth: 240},
{Name: "teleportation", Prob: 5, Worth: 30},
{Name: "stealth", Prob: 7, Worth: 470},
{Name: "maintain armor", Prob: 5, Worth: 380},
},
baseScrInfo: [NumScrollTypes]ObjInfo{
{Name: "monster confusion", Prob: 7, Worth: 140},
{Name: "magic mapping", Prob: 4, Worth: 150},
{Name: "hold monster", Prob: 2, Worth: 180},
{Name: "sleep", Prob: 3, Worth: 5},
{Name: "enchant armor", Prob: 7, Worth: 160},
{Name: "identify potion", Prob: 10, Worth: 80},
{Name: "identify scroll", Prob: 10, Worth: 80},
{Name: "identify weapon", Prob: 6, Worth: 80},
{Name: "identify armor", Prob: 7, Worth: 100},
{Name: "identify ring, wand or staff", Prob: 10, Worth: 115},
{Name: "scare monster", Prob: 3, Worth: 200},
{Name: "food detection", Prob: 2, Worth: 60},
{Name: "teleportation", Prob: 5, Worth: 165},
{Name: "enchant weapon", Prob: 8, Worth: 150},
{Name: "create monster", Prob: 4, Worth: 75},
{Name: "remove curse", Prob: 7, Worth: 105},
{Name: "aggravate monsters", Prob: 3, Worth: 20},
{Name: "protect armor", Prob: 2, Worth: 250},
},
baseWeapInfo: [NumWeaponTypes + 1]ObjInfo{
{Name: "mace", Prob: 11, Worth: 8},
{Name: "long sword", Prob: 11, Worth: 15},
{Name: "short bow", Prob: 12, Worth: 15},
{Name: "arrow", Prob: 12, Worth: 1},
{Name: "dagger", Prob: 8, Worth: 3},
{Name: "two handed sword", Prob: 10, Worth: 75},
{Name: "dart", Prob: 12, Worth: 2},
{Name: "shuriken", Prob: 12, Worth: 5},
{Name: "spear", Prob: 12, Worth: 5},
{}, // DO NOT REMOVE: fake entry for dragon's breath
},
//nolint:dupl // distinct C data tables that merely share their shape
baseWsInfo: [NumWandTypes]ObjInfo{
{Name: "light", Prob: 12, Worth: 250},
{Name: "invisibility", Prob: 6, Worth: 5},
{Name: "lightning", Prob: 3, Worth: 330},
{Name: "fire", Prob: 3, Worth: 330},
{Name: "cold", Prob: 3, Worth: 330},
{Name: "polymorph", Prob: 15, Worth: 310},
{Name: "magic missile", Prob: 10, Worth: 170},
{Name: "haste monster", Prob: 10, Worth: 5},
{Name: "slow monster", Prob: 11, Worth: 350},
{Name: "drain life", Prob: 9, Worth: 300},
{Name: "nothing", Prob: 1, Worth: 5},
{Name: "teleport away", Prob: 6, Worth: 340},
{Name: "teleport to", Prob: 6, Worth: 50},
{Name: "cancellation", Prob: 5, Worth: 280},
},
rainbow: []string{
"amber", "aquamarine", "black", "blue", "brown", "clear", "crimson",
"cyan", "ecru", "gold", "green", "grey", "magenta", "orange", "pink",
"plaid", "purple", "red", "silver", "tan", "tangerine", "topaz",
"turquoise", "vermilion", "violet", "white", "yellow",
},
//nolint:misspell // "ther" is a C scroll syllable, kept faithfully
sylls: []string{
"a", "ab", "ag", "aks", "ala", "an", "app", "arg", "arze", "ash",
"bek", "bie", "bit", "bjor", "blu", "bot", "bu", "byt", "comp",
"con", "cos", "cre", "dalf", "dan", "den", "do", "e", "eep", "el",
"eng", "er", "ere", "erk", "esh", "evs", "fa", "fid", "fri", "fu",
"gan", "gar", "glen", "gop", "gre", "ha", "hyd", "i", "ing", "ip",
"ish", "it", "ite", "iv", "jo", "kho", "kli", "klis", "la", "lech",
"mar", "me", "mi", "mic", "mik", "mon", "mung", "mur", "nej",
"nelg", "nep", "ner", "nes", "nes", "nih", "nin", "o", "od", "ood",
"org", "orn", "ox", "oxy", "pay", "ple", "plu", "po", "pot",
"prok", "re", "rea", "rhov", "ri", "ro", "rog", "rok", "rol", "sa",
"san", "sat", "sef", "seh", "shu", "ski", "sna", "sne", "snik",
"sno", "so", "sol", "sri", "sta", "sun", "ta", "tab", "tem",
"ther", "ti", "tox", "trol", "tue", "turs", "u", "ulk", "um", "un",
"uni", "ur", "val", "viv", "vly", "vom", "wah", "wed", "werg",
"wex", "whon", "wun", "xo", "y", "yot", "yu", "zant", "zeb", "zim",
"zok", "zon", "zum",
},
stoneTable: []Stone{
{"agate", 25}, {"alexandrite", 40}, {"amethyst", 50},
{"carnelian", 40}, {"diamond", 300}, {"emerald", 300},
{"germanium", 225}, {"granite", 5}, {"garnet", 50},
{"jade", 150}, {"kryptonite", 300}, {"lapis lazuli", 50},
{"moonstone", 50}, {"obsidian", 15}, {"onyx", 60},
{"opal", 200}, {"pearl", 220}, {"peridot", 63},
{"ruby", 350}, {"sapphire", 285}, {"stibotantalite", 200},
{"tiger eye", 50}, {"topaz", 60}, {"turquoise", 70},
{"taaffeite", 300}, {"zircon", 80},
},
woods: []string{
"avocado wood", "balsa", "bamboo", "banyan", "birch", "cedar",
"cherry", "cinnibar", "cypress", "dogwood", "driftwood", "ebony",
"elm", "eucalyptus", "fall", "hemlock", "holly", "ironwood",
"kukui wood", "mahogany", "manzanita", "maple", "oaken",
"persimmon wood", "pecan", "pine", "poplar", "redwood", "rosewood",
"spruce", "teak", "walnut", "zebrawood",
},
metals: []string{
"aluminum", "beryllium", "bone", "brass", "bronze", "copper",
"electrum", "gold", "iron", "lead", "magnesium", "mercury",
"nickel", "pewter", "platinum", "steel", "silver", "silicon",
"tin", "titanium", "tungsten", "zinc",
},
helpStr: []helpEntry{
{'?', " prints help", true},
{'/', " identify object", true},
{'h', " left", true},
{'j', " down", true},
{'k', " up", true},
{'l', " right", true},
{'y', " up & left", true},
{'u', " up & right", true},
{'b', " down & left", true},
{'n', " down & right", true},
{'H', " run left", false},
{'J', " run down", false},
{'K', " run up", false},
{'L', " run right", false},
{'Y', " run up & left", false},
{'U', " run up & right", false},
{'B', " run down & left", false},
{'N', " run down & right", false},
{CTRL('H'), " run left until adjacent", false},
{CTRL('J'), " run down until adjacent", false},
{CTRL('K'), " run up until adjacent", false},
{CTRL('L'), " run right until adjacent", false},
{CTRL('Y'), " run up & left until adjacent", false},
{CTRL('U'), " run up & right until adjacent", false},
{CTRL('B'), " run down & left until adjacent", false},
{CTRL('N'), " run down & right until adjacent", false},
{0, " <SHIFT><dir>: run that way", true},
{0, " <CTRL><dir>: run till adjacent", true},
{'f', "<dir> fight till death or near death", true},
{'t', "<dir> throw something", true},
{'m', "<dir> move onto without picking up", true},
{'z', "<dir> zap a wand in a direction", true},
{'^', "<dir> identify trap type", true},
{'s', " search for trap/secret door", true},
{'>', " go down a staircase", true},
{'<', " go up a staircase", true},
{'.', " rest for a turn", true},
{',', " pick something up", true},
{'i', " inventory", true},
{'I', " inventory single item", true},
{'q', " quaff potion", true},
{'r', " read scroll", true},
{'e', " eat food", true},
{'w', " wield a weapon", true},
{'W', " wear armor", true},
{'T', " take armor off", true},
{'P', " put on ring", true},
{'R', " remove ring", true},
{'d', " drop object", true},
{'c', " call object", true},
{'a', " repeat last command", true},
{')', " print current weapon", true},
{']', " print current armor", true},
{'=', " print current rings", true},
{'@', " print current stats", true},
{'D', " recall what's been discovered", true},
{'o', " examine/set options", true},
{CTRL('R'), " redraw screen", true},
{CTRL('P'), " repeat last message", true},
{Escape, " cancel command", true},
{'S', " save game", true},
{'Q', " quit", true},
{'!', " shell escape", true},
{'F', "<dir> fight till either of you dies", true},
{'v', " print version number", true},
},
hNames: [8]string{
" scored an excellent hit on ",
" hit ",
" have injured ",
" swing and hit ",
" scored an excellent hit on ",
" hit ",
" has injured ",
" swings and hits ",
},
mNames: [8]string{
" miss",
" swing and miss",
" barely miss",
" don't hit",
" misses",
" swings and misses",
" barely misses",
" doesn't hit",
},
strPlus: [32]int{
-7, -6, -5, -4, -3, -2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1,
1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3,
},
addDam: [32]int{
-7, -6, -5, -4, -3, -2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 3,
3, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6,
},
lvlMons: [26]byte{
'K', 'E', 'B', 'S', 'H', 'I', 'R', 'O', 'Z', 'L', 'C', 'Q', 'A',
'N', 'Y', 'F', 'T', 'W', 'P', 'X', 'U', 'M', 'V', 'G', 'J', 'D',
},
wandMons: [26]byte{
'K', 'E', 'B', 'S', 'H', 0, 'R', 'O', 'Z', 0, 'C', 'Q', 'A',
0, 'Y', 0, 'T', 'W', 'P', 0, 'U', 'M', 'V', 'G', 'J', 0,
},
ringUses: [NumRingTypes]int{
1, // R_PROTECT
1, // R_ADDSTR
1, // R_SUSTSTR
-3, // R_SEARCH
-5, // R_SEEINVIS
0, // R_NOP
0, // R_AGGR
-3, // R_ADDHIT
-3, // R_ADDDAM
2, // R_REGEN
-2, // R_DIGEST
0, // R_TELEPORT
1, // R_STEALTH
1, // R_SUSTARM
},
initWeaps: [NumWeaponTypes]weaponSetup{
{dice("2x4"), dice("1x3"), noWeapon, 0}, // WeaponMace
{dice("3x4"), dice("1x2"), noWeapon, 0}, // Long sword
{dice("1x1"), dice("1x1"), noWeapon, 0}, // WeaponBow
{dice("1x1"), dice("2x3"), WeaponBow, Stackable | Missile}, // WeaponArrow
{dice("1x6"), dice("1x4"), noWeapon, Missile}, // WeaponDagger
{dice("4x4"), dice("1x2"), noWeapon, 0}, // 2h sword
{dice("1x1"), dice("1x3"), noWeapon, Stackable | Missile}, // WeaponDart
{dice("1x2"), dice("2x4"), noWeapon, Stackable | Missile}, // Shuriken
{dice("2x3"), dice("1x6"), noWeapon, Missile}, // WeaponSpear
},
pActions: [NumPotionTypes]pact{
PotionConfusion: {Confused, DUnconfuse, HuhDuration,
"what a tripy feeling!",
"wait, what's going on here. Huh? What? Who?"},
PotionLSD: {Hallucinating, DComeDown, SeeDuration,
"Oh, wow! Everything seems so cosmic!",
"Oh, wow! Everything seems so cosmic!"},
PotionSeeInvisible: {CanSeeInvisible, DUnsee, SeeDuration, "", ""},
PotionBlindness: {Blind, DSight, SeeDuration,
"oh, bummer! Everything is dark! Help!",
"a cloak of darkness falls around you"},
PotionLevitation: {Levitating, DLand, HealTime,
"oh, wow! You're floating in the air!",
"you start to float in the air"},
},
idType: [ScrollIdentifyRingOrStick + 1]ObjectKind{
ScrollIdentifyPotion: KindPotion,
ScrollIdentifyScroll: KindScroll,
ScrollIdentifyWeapon: KindWeapon,
ScrollIdentifyArmor: KindArmor,
ScrollIdentifyRingOrStick: KindRingOrStick,
},
rdesConn: [MaxRooms][MaxRooms]bool{
{false, true, false, true, false, false, false, false, false},
{true, false, true, false, true, false, false, false, false},
{false, true, false, false, false, true, false, false, false},
{true, false, false, false, true, false, true, false, false},
{false, true, false, true, false, true, false, true, false},
{false, false, true, false, true, false, false, false, true},
{false, false, false, true, false, false, false, true, false},
{false, false, false, false, true, false, true, false, true},
{false, false, false, false, false, true, false, true, false},
},
thingList: []byte{
Potion, Scroll, Ring, Stick, Food, Weapon, Armor, Stairs, Gold, Amulet,
},
identList: []helpEntry{
{'|', "wall of a room", false},
{'-', "wall of a room", false},
{Gold, goldName, false},
{Stairs, "a staircase", false},
{Door, "door", false},
{Floor, "room floor", false},
{PlayerCh, "you", false},
{Passage, "passage", false},
{Trap, "trap", false},
{Potion, potionName, false},
{Scroll, scrollName, false},
{Food, "food", false},
{Weapon, "weapon", false},
{' ', "solid rock", false},
{Armor, "armor", false},
{Amulet, "the Amulet of Yendor", false},
{Ring, ringName, false},
{Stick, "wand or staff", false},
},
hungerStateName: [4]string{"", "Hungry", "Weak", "Faint"},
ripArt: []string{
" __________",
" / \\",
" / REST \\",
" / IN \\",
" / PEACE \\",
" / \\",
ripWall,
ripWall,
" | killed by a |",
ripWall,
" | 1980 |",
" *| * * * | *",
" ________)/\\\\_//(\\/(/\\)/\\//\\/|_)_______",
},
killnameTable: []helpEntry{
{'a', "arrow", true},
{'b', "bolt", true},
{'d', "dart", true},
{'h', "hypothermia", false},
{'s', "starvation", false},
},
scoreReasons: [4]string{
"killed",
"quit",
"A total winner",
"killed with Amulet",
},
quaffHandlers: [NumPotionTypes]func(*RogueGame, bool){
PotionConfusion: (*RogueGame).quaffConfusion,
PotionLSD: (*RogueGame).quaffLSD,
PotionPoison: (*RogueGame).quaffPoison,
PotionGainStrength: (*RogueGame).quaffGainStrength,
PotionSeeInvisible: (*RogueGame).quaffSeeInvisible,
PotionHealing: (*RogueGame).quaffHealing,
PotionDetectMonsters: (*RogueGame).quaffDetectMonsters,
PotionDetectMagic: (*RogueGame).quaffDetectMagic,
PotionRaiseLevel: (*RogueGame).quaffRaiseLevel,
PotionExtraHealing: (*RogueGame).quaffExtraHealing,
PotionHaste: (*RogueGame).quaffHaste,
PotionRestoreStrength: (*RogueGame).quaffRestoreStrength,
PotionBlindness: (*RogueGame).quaffBlindness,
PotionLevitation: (*RogueGame).quaffLevitation,
},
readHandlers: [NumScrollTypes]func(*RogueGame, *Object){
ScrollMonsterConfusion: (*RogueGame).readMonsterConfusion,
ScrollMagicMapping: (*RogueGame).readMagicMapping,
ScrollHoldMonster: (*RogueGame).readHoldMonster,
ScrollSleep: (*RogueGame).readSleep,
ScrollEnchantArmor: (*RogueGame).readEnchantArmor,
ScrollIdentifyPotion: (*RogueGame).readIdentify,
ScrollIdentifyScroll: (*RogueGame).readIdentify,
ScrollIdentifyWeapon: (*RogueGame).readIdentify,
ScrollIdentifyArmor: (*RogueGame).readIdentify,
ScrollIdentifyRingOrStick: (*RogueGame).readIdentify,
ScrollScareMonster: (*RogueGame).readScareMonster,
ScrollFoodDetection: (*RogueGame).readFoodDetection,
ScrollTeleportation: (*RogueGame).readTeleportation,
ScrollEnchantWeapon: (*RogueGame).readEnchantWeapon,
ScrollCreateMonster: (*RogueGame).readCreateMonster,
ScrollRemoveCurse: (*RogueGame).readRemoveCurse,
ScrollAggravateMonsters: (*RogueGame).readAggravateMonsters,
ScrollProtectArmor: (*RogueGame).readProtectArmor,
},
zapHandlers: [NumWandTypes]func(*RogueGame, *Object) bool{
WandLight: (*RogueGame).zapLight,
WandInvisibility: (*RogueGame).zapInvisibility,
WandLightning: (*RogueGame).zapBolt,
WandFire: (*RogueGame).zapBolt,
WandCold: (*RogueGame).zapBolt,
WandPolymorph: (*RogueGame).zapPolymorph,
WandMagicMissile: (*RogueGame).zapMagicMissile,
WandHasteMonster: (*RogueGame).zapSpeed,
WandSlowMonster: (*RogueGame).zapSpeed,
WandDrainLife: (*RogueGame).zapDrainLife,
WandTeleportAway: (*RogueGame).zapTeleport,
WandTeleportTo: (*RogueGame).zapTeleport,
WandCancellation: (*RogueGame).zapCancellation,
},
hitHandlers: [26]func(*RogueGame, *Monster, string) bool{
0: (*RogueGame).hitAquator, // 'A' - 'A'
'F' - 'A': (*RogueGame).hitFlytrap,
'I' - 'A': (*RogueGame).hitIceMonster,
'L' - 'A': (*RogueGame).hitLeprechaun,
'N' - 'A': (*RogueGame).hitNymph,
'R' - 'A': (*RogueGame).hitRattlesnake,
'V' - 'A': (*RogueGame).hitLifeDrainer,
'W' - 'A': (*RogueGame).hitLifeDrainer,
},
commandHandlers: map[byte]func(*RogueGame){
',': (*RogueGame).pickupCommand,
'!': (*RogueGame).shell,
'h': func(g *RogueGame) { g.moveHero(0, -1) },
'j': func(g *RogueGame) { g.moveHero(1, 0) },
'k': func(g *RogueGame) { g.moveHero(-1, 0) },
'l': func(g *RogueGame) { g.moveHero(0, 1) },
'y': func(g *RogueGame) { g.moveHero(-1, -1) },
'u': func(g *RogueGame) { g.moveHero(-1, 1) },
'b': func(g *RogueGame) { g.moveHero(1, -1) },
'n': func(g *RogueGame) { g.moveHero(1, 1) },
'H': func(g *RogueGame) { g.startRun('h') },
'J': func(g *RogueGame) { g.startRun('j') },
'K': func(g *RogueGame) { g.startRun('k') },
'L': func(g *RogueGame) { g.startRun('l') },
'Y': func(g *RogueGame) { g.startRun('y') },
'U': func(g *RogueGame) { g.startRun('u') },
'B': func(g *RogueGame) { g.startRun('b') },
'N': func(g *RogueGame) { g.startRun('n') },
't': func(g *RogueGame) {
if !g.promptDirection() {
g.After = false
} else {
g.missile(g.Delta.Y, g.Delta.X)
}
},
'q': (*RogueGame).quaff,
'Q': func(g *RogueGame) {
g.After = false
g.QComm = true
g.quit(0)
g.QComm = false
},
'i': func(g *RogueGame) {
g.After = false
g.inventory(g.Player.Pack, 0)
},
'I': func(g *RogueGame) {
g.After = false
g.pickyInven()
},
'd': (*RogueGame).dropIt,
'r': (*RogueGame).readScroll,
'e': (*RogueGame).eat,
'w': (*RogueGame).wield,
'W': (*RogueGame).wear,
'T': (*RogueGame).takeOff,
'P': (*RogueGame).ringOn,
'R': (*RogueGame).ringOff,
'o': func(g *RogueGame) {
g.option()
g.After = false
},
'c': func(g *RogueGame) {
g.call()
g.After = false
},
'>': func(g *RogueGame) {
g.After = false
g.dLevel()
},
'<': func(g *RogueGame) {
g.After = false
g.uLevel()
},
'?': func(g *RogueGame) {
g.After = false
g.help()
},
'/': func(g *RogueGame) {
g.After = false
g.identify()
},
's': (*RogueGame).search,
'z': func(g *RogueGame) {
if g.promptDirection() {
g.doZap()
} else {
g.After = false
}
},
'D': func(g *RogueGame) {
g.After = false
g.discovered()
},
CTRL('P'): func(g *RogueGame) {
g.After = false
g.msg("%s", g.Msgs.Huh)
},
CTRL('R'): func(g *RogueGame) {
g.After = false
// 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
g.msg("version %s. (mctesq was here)", Release)
},
'S': func(g *RogueGame) {
g.After = false
g.saveGame()
},
'.': func(*RogueGame) {
// rest command
},
' ': func(g *RogueGame) {
g.After = false // "legal" illegal command
},
'^': (*RogueGame).identifyTrapCommand,
'+': (*RogueGame).wizardToggleCommand,
Escape: func(g *RogueGame) {
g.DoorStop = false
g.Count = 0
g.After = false
g.Again = false
},
')': func(g *RogueGame) {
g.current(g.Player.CurWeapon, "wielding", "")
},
']': func(g *RogueGame) {
g.current(g.Player.CurArmor, "wearing", "")
},
'=': func(g *RogueGame) {
g.current(g.Player.CurRing[Left], "wearing",
g.chooseTerse("(L)", "on left hand"))
g.current(g.Player.CurRing[Right], "wearing",
g.chooseTerse("(R)", "on right hand"))
},
'@': func(g *RogueGame) {
g.StatMsg = true
g.status()
g.StatMsg = false
g.After = false
},
},
trapHandlers: [NumTrapTypes]func(*RogueGame, Coord){
TrapDoor: (*RogueGame).trapFall,
TrapArrow: (*RogueGame).trapArrow,
TrapSleep: (*RogueGame).trapSleep,
TrapBear: (*RogueGame).trapBear,
TrapTeleport: (*RogueGame).trapTeleport,
TrapDart: (*RogueGame).trapDart,
TrapRust: (*RogueGame).trapRust,
TrapMystery: (*RogueGame).trapMystery,
},
daemonHandlers: [DTurnSee + 1]func(*RogueGame, int){
DRollwand: (*RogueGame).rollwand,
DDoctor: (*RogueGame).doctor,
DStomach: (*RogueGame).stomach,
DRunners: (*RogueGame).runners,
DSwander: (*RogueGame).swander,
DNohaste: (*RogueGame).nohaste,
DUnconfuse: (*RogueGame).unconfuse,
DUnsee: (*RogueGame).unsee,
DSight: (*RogueGame).sight,
DVisuals: (*RogueGame).visuals,
DComeDown: (*RogueGame).comeDown,
DLand: (*RogueGame).land,
DTurnSee: func(g *RogueGame, arg int) {
g.turnSee(arg != 0)
},
},
}
}
// The three effect-table lookups below are the guarded form of a raw
// index into quaffHandlers/readHandlers/zapHandlers. An object whose
// Which is out of range for its kind reports no handler rather than
// panicking, which lands on the same do-nothing behavior C's switches
// had when no case matched.
// quaffHandler returns the potion effect for obj, or nil when obj is not
// a potion the table knows about.
func (d *gameData) quaffHandler(obj *Object) func(g *RogueGame, trip bool) {
if !obj.hasValidWhich() {
return nil
}
return d.quaffHandlers[obj.PotionKind()]
}
// readHandler returns the scroll effect for obj, or nil when obj is not
// a scroll the table knows about.
func (d *gameData) readHandler(obj *Object) func(g *RogueGame, obj *Object) {
if !obj.hasValidWhich() {
return nil
}
return d.readHandlers[obj.ScrollKind()]
}
// zapHandler returns the wand effect for obj, or nil when obj is not a
// wand the table knows about.
func (d *gameData) zapHandler(obj *Object) func(g *RogueGame, obj *Object) bool {
if !obj.hasValidWhich() {
return nil
}
return d.zapHandlers[obj.WandKind()]
}
// identifyType reads extern.c's identify-scroll to item-kind map,
// returning KindNone for any scroll past the last identify scroll (the
// table is shorter than the scroll table it is keyed by). No scroll that
// can reach readIdentify is past that end, so the guard is defensive
// rather than a live bound; it exists so a future table resize cannot
// turn the lookup into a panic.
func (d *gameData) identifyType(kind ScrollKind) ObjectKind {
if kind < 0 || int(kind) >= len(d.idType) {
return KindNone
}
return d.idType[kind]
}
// armorClass reads extern.c a_class[] for an armor object's Which,
// returning 0 when Which is out of range. Only a malformed object can
// hit that arm — createObj and Restore both reject one — but a bounds
// check here is what keeps a bad index from panicking the process
// instead of merely naming a suit of armor oddly.
func (d *gameData) armorClass(which int) int {
if which < 0 || which >= int(NumArmorTypes) {
return 0
}
return d.aClass[which]
}
// Version strings (vers.c). The encstr/statlist XOR keys are not ported:
// the Go save format does not use them.
const (
Release = "5.4.4"
Version = "rogue (git.eeqj.de/sneak/rgoue port of rogueforge 5.4.4)"
)

132
game/tables_test.go Normal file
View File

@@ -0,0 +1,132 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import "testing"
// badcheck from init.c: every probability table must sum to exactly 100.
func TestProbabilitiesSumTo100(t *testing.T) {
t.Parallel()
sum := func(info []ObjInfo) int {
s := 0
for _, oi := range info {
s += oi.Prob
}
return s
}
data := newGameData()
tables := map[string][]ObjInfo{
"things": data.baseThings[:],
"potions": data.basePotInfo[:],
"scrolls": data.baseScrInfo[:],
"rings": data.baseRingInfo[:],
"sticks": data.baseWsInfo[:],
"weapons": data.baseWeapInfo[:NumWeaponTypes], // excludes the flame entry
"armor": data.baseArmInfo[:],
}
for name, tab := range tables {
if s := sum(tab); s != 100 {
t.Errorf("bad percentages for %s: sum = %d, want 100", name, s)
}
}
}
func TestInitProbsCumulative(t *testing.T) {
t.Parallel()
g := New(Params{Seed: 1})
last := g.Items.Potions[NumPotionTypes-1].Prob
if last != 100 {
t.Errorf("cumulative potion probability ends at %d, want 100", last)
}
for i := PotionKind(1); i < NumPotionTypes; i++ {
if g.Items.Potions[i].Prob < g.Items.Potions[i-1].Prob {
t.Errorf("potion probs not nondecreasing at %d", i)
}
}
}
func TestNewGameRandomizesAppearances(t *testing.T) {
t.Parallel()
g := New(Params{Seed: 12345})
checkPotionColors(t, g)
checkScrollNames(t, g)
checkWandMaterials(t, g)
// Determinism: same seed, same appearances.
h := New(Params{Seed: 12345})
if h.Items != g.Items {
t.Error("two games with the same seed produced different item lore")
}
}
// checkPotionColors verifies every potion has a distinct color.
func checkPotionColors(t *testing.T, g *RogueGame) {
t.Helper()
seen := map[string]bool{}
for i, c := range g.Items.PotColors {
if c == "" {
t.Fatalf("potion %d has no color", i)
}
if seen[c] {
t.Errorf("potion color %q assigned twice", c)
}
seen[c] = true
}
}
// checkScrollNames verifies every scroll has a name within the C buffer
// limit.
func checkScrollNames(t *testing.T, g *RogueGame) {
t.Helper()
for i, n := range g.Items.ScrNames {
if n == "" {
t.Fatalf("scroll %d has no name", i)
}
if len(n) > MaxNameLen+1 {
t.Errorf("scroll name %q longer than C buffer allows", n)
}
}
}
// checkWandMaterials verifies every stick has a wand/staff type and a
// material.
func checkWandMaterials(t *testing.T, g *RogueGame) {
t.Helper()
for i := range g.Items.WandType {
if g.Items.WandType[i] != wandName && g.Items.WandType[i] != staffName {
t.Errorf("stick %d has type %q", i, g.Items.WandType[i])
}
if g.Items.WandMade[i] == "" {
t.Errorf("stick %d has no material", i)
}
}
}
func TestMonsterTable(t *testing.T) {
t.Parallel()
data := newGameData()
if data.monsterTable[0].Name != "aquator" || data.monsterTable[25].Name != "zombie" {
t.Error("monster table order broken")
}
if data.monsterTable['D'-'A'].Name != "dragon" {
t.Error("letter indexing broken")
}
}

43
game/term_test.go Normal file
View File

@@ -0,0 +1,43 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
// testTerm is a headless Terminal for tests: rendering is a no-op and
// input plays a script, then alternates space/newline so that --More--
// and [Press return] prompts never block.
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.
// The blocking case has its own fake, blockingTerm in autosave_test.go.
func (t *testTerm) Interrupt() {}
func (t *testTerm) ReadChar() (byte, bool) {
if t.pos < len(t.input) {
c := t.input[t.pos]
t.pos++
return c, true
}
t.tick++
if t.tick%2 == 0 {
return '\n', true
}
return ' ', true
}

27
game/testdata/README.md vendored Normal file
View File

@@ -0,0 +1,27 @@
# Seed-compatibility golden
`item_tables.golden` is the per-seed item appearance tables (potion colors,
scroll names, ring stones, wand/staff materials) captured from the **C
reference** on the `modern-rogue` branch, for the seeds in the `seeds` list in
`TestSeedCompatItemTables`. That test regenerates the same tables from the Go
port and checks they match byte for byte — proving the LCG and its consumption
order through the whole init sequence (`init_probs``init_player`
`init_names``init_colors``init_stones``init_materials`) agree with C.
## Regenerating the golden
`c_seedcompat.patch` adds a `DUMP` mode to the C `main.c`: with `DUMP` set it
forces the RNG seed from `SEED`, runs the item-table init in the normal order,
prints the tables, and exits before `initscr` (so no terminal is needed).
```sh
# from a checkout of the C reference (modern-rogue branch):
git archive modern-rogue | tar -x -C /tmp/rogue-c
cd /tmp/rogue-c
patch -p1 < .../game/testdata/c_seedcompat.patch
./configure && make
for s in 1 42 12345 99999; do DUMP=1 SEED=$s ./rogue; done \
> .../game/testdata/item_tables.golden
```
The seed list must match the `seeds` slice in `TestSeedCompatItemTables`.

37
game/testdata/c_seedcompat.patch vendored Normal file
View File

@@ -0,0 +1,37 @@
--- a/main.c 2026-07-24 03:02:38
+++ b/main.c 2026-07-24 02:48:31
@@ -63,6 +63,34 @@
#endif
dnum = lowtime + md_getpid();
seed = dnum;
+
+ /* SEEDCOMPAT: dump the per-game item appearance tables for a fixed
+ * seed and exit, without initscr. The init sequence and everything
+ * it consumes from rnd() mirror the normal startup (main.c), so the
+ * tables are exactly what a real game with SEED would show. */
+ if (getenv("DUMP") != NULL)
+ {
+ int di;
+ char *sv = getenv("SEED");
+ if (sv != NULL)
+ seed = atoi(sv);
+ printf("SEED %d\n", seed);
+ init_probs();
+ init_player();
+ init_names();
+ init_colors();
+ init_stones();
+ init_materials();
+ printf("POTIONS\n");
+ for (di = 0; di < MAXPOTIONS; di++) printf("%s\n", p_colors[di]);
+ printf("SCROLLS\n");
+ for (di = 0; di < MAXSCROLLS; di++) printf("%s\n", s_names[di]);
+ printf("RINGS\n");
+ for (di = 0; di < MAXRINGS; di++) printf("%s\n", r_stones[di]);
+ printf("STICKS\n");
+ for (di = 0; di < MAXSTICKS; di++) printf("%s %s\n", ws_type[di], ws_made[di]);
+ exit(0);
+ }
open_score();

260
game/testdata/item_tables.golden vendored Normal file
View File

@@ -0,0 +1,260 @@
SEED 1
POTIONS
tangerine
white
ecru
gold
amber
violet
vermilion
pink
aquamarine
plaid
clear
orange
cyan
tan
SCROLLS
miwhon garsnanih
xomimi roke eshwedshu
potwexrol ipbjorod turs evsnelg
bekornan oxyfatox
iv wexpo wun
ha sefnelgtue whon pay
alari wedit
zantmon umzonski umwhonjo yot
bluoxun rokkho yottrol sta
vomarg microgcomp iteulkshu mung
jo urokeep yuskiun
ox xozantaks klisstaevs ag
ipnih bek
shu ami erk
nejti zim
iprol mic ishoxyvom fagan
reacreti oodrol
bytsri solsa tabu fri
RINGS
agate
zircon
jade
tiger eye
onyx
germanium
lapis lazuli
emerald
taaffeite
kryptonite
garnet
ruby
turquoise
pearl
STICKS
wand steel
wand platinum
staff redwood
staff pine
wand silicon
staff spruce
staff pecan
wand bone
staff maple
wand zinc
wand iron
wand pewter
wand electrum
staff dogwood
SEED 42
POTIONS
blue
green
grey
amber
violet
gold
pink
tan
purple
yellow
plaid
magenta
turquoise
cyan
SCROLLS
bek itod oxytaod oxy
tarhovzant cre sname oxroy
plemik ganod hyd wergerkpot
hyd sol um bekzok
esh eep ganmung
anera ishsa ingala mon
alasniklech viv
yunejorn garro con nej
dotrolther gopum eltitrol trolmonsri
nes alazum
itegopmung ti
ere haeta wergla
nejerecre poipi iprea
ha falechrhov
monskiwex sabitla frido
rhovmar sno
mar bekurzant satbuzum
somon sri
RINGS
carnelian
onyx
jade
granite
stibotantalite
kryptonite
lapis lazuli
germanium
garnet
tiger eye
opal
topaz
agate
peridot
STICKS
staff birch
staff ebony
staff redwood
wand gold
wand copper
wand aluminum
wand titanium
wand mercury
staff cypress
staff bamboo
staff dogwood
wand silicon
staff zebrawood
wand beryllium
SEED 12345
POTIONS
purple
black
grey
brown
plaid
violet
vermilion
ecru
orange
turquoise
tan
magenta
silver
gold
SCROLLS
readalf shuplu ivnin
plelaiv solel skibyt monha
xo wun
wedyfri o ewhonxo favompay
eep zantreanelg
plu buxo
un zontabdan
bie snik
ulkitzant bluri
apporg ash posnevly dennepwex
u urval rol
arzepotsno snovly pay snoropay
pottox erewed faoxro
ther sun ulkipo mik
argzebfri elgrekli tuenepzon sehturssef
isheep blumur
wedash yuzimsun
plupofri ski rejo fa
RINGS
onyx
tiger eye
alexandrite
turquoise
pearl
emerald
germanium
sapphire
zircon
ruby
granite
stibotantalite
opal
diamond
STICKS
wand silicon
staff ironwood
staff holly
wand gold
staff mahogany
wand iron
wand brass
wand pewter
staff hemlock
staff cherry
staff elm
wand mercury
staff banyan
staff dogwood
SEED 99999
POTIONS
aquamarine
plaid
gold
black
vermilion
red
cyan
tan
orange
violet
brown
clear
silver
green
SCROLLS
zant jocompan vomervly
mur shusat prok
prokmurklis oxysriklis
ingcre prokbu whonengarg kli
kli bot
rokcoswerg ipsolsan klisvlypay
glen yot whontox
lechme markho fazim
dalfsunbie micjosef cre comp
vlyfumi bjorzantbot werg
po argfidcos klipones
ashtemarg ycrezim dalfiv whon
turs unmisa zimpo therdo
miccompuni uni neswex sef
odwexing elwergmur mung
itcon rhov nejmic lech
garturs engseh ganish
oodta whonorgsno monabmik vomyeng
RINGS
obsidian
moonstone
jade
carnelian
tiger eye
taaffeite
turquoise
stibotantalite
agate
ruby
onyx
topaz
germanium
granite
STICKS
wand titanium
wand brass
wand silicon
staff zebrawood
wand mercury
staff dogwood
wand pewter
staff cinnibar
staff kukui wood
staff banyan
wand magnesium
wand gold
staff maple
wand nickel

746
game/things.go Normal file
View File

@@ -0,0 +1,746 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import (
"fmt"
"strings"
)
// things.c — item naming, random item creation, and the discovery list.
// invName returns the name of something as it would appear in an inventory
// (things.c inv_name).
func (g *RogueGame) inventoryName(obj *Object, drop bool) string {
var pb strings.Builder
// Every arm below reaches into a per-kind name table at obj.Which:
// the potion colors, ring stones, wand material/type, scroll titles,
// and the weapon and armor name tables. An object with an out-of-range
// Which cannot reach here (createObj and Restore both reject one), but
// if one ever did, naming it must not take the process down — so it
// falls back to the bare category name from C's type_name() vocabulary.
if !obj.hasValidWhich() {
return obj.Kind.String()
}
which := obj.Which
it := &g.Items
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.Kind {
case KindPotion:
g.nameit(&pb, obj, potionName, it.PotColors[which], &it.Potions[which], nullstr)
case KindRing:
g.nameit(&pb, obj, ringName, it.RingStones[which], &it.Rings[which], ringNum)
case KindWand:
g.nameit(&pb, obj, it.WandType[which], it.WandMade[which],
&it.Sticks[which], chargeStr)
case KindScroll:
g.nameScroll(&pb, obj)
case KindFood:
g.nameFood(&pb, obj)
case KindWeapon:
g.nameWeapon(&pb, obj)
case KindArmor:
g.nameArmor(&pb, obj)
case KindAmulet:
pb.WriteString("The Amulet of Yendor")
case KindGold:
fmt.Fprintf(&pb, "%d Gold pieces", obj.GoldValue)
}
return fixNameCase(g.describeWorn(obj, pb.String()), drop)
}
// nameScroll writes a scroll's inventory name (things.c inv_name).
func (g *RogueGame) nameScroll(pb *strings.Builder, obj *Object) {
if obj.Count == 1 {
pb.WriteString("A scroll ")
} else {
fmt.Fprintf(pb, "%d scrolls ", obj.Count)
}
op := &g.Items.Scrolls[obj.Which]
switch {
case op.Know:
fmt.Fprintf(pb, "of %s", op.Name)
case op.Guess != "":
fmt.Fprintf(pb, "called %s", op.Guess)
default:
fmt.Fprintf(pb, "titled '%s'", g.Items.ScrNames[obj.Which])
}
}
// nameFood writes a food item's inventory name; which 1 is the fruit
// (things.c inv_name).
func (g *RogueGame) nameFood(pb *strings.Builder, obj *Object) {
if obj.Which == 1 {
if obj.Count == 1 {
fmt.Fprintf(pb, "A%s %s", vowelstr(g.Fruit), g.Fruit)
} else {
fmt.Fprintf(pb, "%d %ss", obj.Count, g.Fruit)
}
return
}
if obj.Count == 1 {
pb.WriteString("Some food")
} else {
fmt.Fprintf(pb, "%d rations of food", obj.Count)
}
}
// nameWeapon writes a weapon's inventory name (things.c inv_name).
func (g *RogueGame) nameWeapon(pb *strings.Builder, obj *Object) {
sp := g.Items.Weapons[obj.Which].Name
if obj.Count > 1 {
fmt.Fprintf(pb, "%d ", obj.Count)
} else {
fmt.Fprintf(pb, "A%s ", vowelstr(sp))
}
if obj.Flags.Has(Known) {
fmt.Fprintf(pb, "%s %s", num(obj.HPlus, obj.DPlus, Weapon), sp)
} else {
pb.WriteString(sp)
}
if obj.Count > 1 {
pb.WriteString("s")
}
if obj.Label != "" {
fmt.Fprintf(pb, " called %s", obj.Label)
}
}
// nameArmor writes an armor's inventory name (things.c inv_name).
func (g *RogueGame) nameArmor(pb *strings.Builder, obj *Object) {
sp := g.Items.Armors[obj.Which].Name
if obj.Flags.Has(Known) {
fmt.Fprintf(pb, "%s %s [",
num(g.data.armorClass(obj.Which)-obj.ArmorClass, 0, Armor), sp)
if !g.Options.Terse {
pb.WriteString("protection ")
}
fmt.Fprintf(pb, "%d]", 10-obj.ArmorClass)
} else {
pb.WriteString(sp)
}
if obj.Label != "" {
fmt.Fprintf(pb, " called %s", obj.Label)
}
}
// describeWorn appends the equipped-status notes to an inventory name
// (things.c inv_name).
func (g *RogueGame) describeWorn(obj *Object, out string) string {
if !g.InvDescribe {
return out
}
p := &g.Player
if obj == p.CurArmor {
out += " (being worn)"
}
if obj == p.CurWeapon {
out += " (weapon in hand)"
}
switch obj {
case p.CurRing[Left]:
out += " (on left hand)"
case p.CurRing[Right]:
out += " (on right hand)"
}
return out
}
// fixNameCase upper- or lowercases the leading letter to suit the
// sentence it will land in (things.c inv_name).
func fixNameCase(out string, drop bool) string {
if out == "" {
return out
}
if drop && isUpper(out[0]) {
return string(toLower(out[0])) + out[1:]
}
if !drop && isLower(out[0]) {
return string(toUpper(out[0])) + out[1:]
}
return out
}
// dropIt puts something down (things.c drop; renamed to avoid the
// leavePack/detach vocabulary collision).
func (g *RogueGame) dropIt() {
p := &g.Player
ch := g.Level.Char(p.Pos.Y, p.Pos.X)
if ch != Floor && ch != Passage {
g.After = false
g.msg("there is something there already")
return
}
obj, ok := g.promptPackItem("drop", KindNone)
if !ok {
return
}
if !g.dropCheck(obj) {
return
}
obj = g.leavePack(obj, true, !obj.Kind.MergesInPack())
// Link it into the level object list
g.Level.AddObject(obj)
g.Level.SetChar(p.Pos.Y, p.Pos.X, obj.Kind.Glyph())
g.Level.FlagsAt(p.Pos.Y, p.Pos.X).Set(FDropped)
obj.Pos = p.Pos
if obj.Kind == KindAmulet {
g.HasAmulet = false
}
g.msg("dropped %s", g.inventoryName(obj, true))
}
// dropCheck does special checks for dropping or unwielding|unwearing|
// unringing (things.c dropcheck).
func (g *RogueGame) dropCheck(obj *Object) bool {
if obj == nil {
return true
}
p := &g.Player
if obj != p.CurArmor && obj != p.CurWeapon &&
obj != p.CurRing[Left] && obj != p.CurRing[Right] {
return true
}
if obj.Flags.Has(Cursed) {
g.msg("you can't. It appears to be cursed")
return false
}
switch obj {
case p.CurWeapon:
p.CurWeapon = nil
case p.CurArmor:
g.wasteTime()
p.CurArmor = nil
default:
g.dropRing(obj)
}
return true
}
// dropRing takes a worn ring off with its side effects (things.c
// dropcheck).
func (g *RogueGame) dropRing(obj *Object) {
p := &g.Player
hand := Right
if obj == p.CurRing[Left] {
hand = Left
}
p.CurRing[hand] = nil
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.RingKind() {
case RingAddStrength:
g.changeStrength(-obj.Bonus)
case RingSeeInvisible:
g.unsee(0)
g.Extinguish(DUnsee)
}
}
// newThing returns a new random thing for the dungeon (things.c new_thing).
func (g *RogueGame) newThing() *Object {
cur := newObject()
cur.Damage = dice("0x0")
cur.HurlDmg = dice("0x0")
cur.ArmorClass = 11
cur.Count = 1
// Decide what kind of object it will be; if we haven't had food for a
// while, let it be food.
var kind int
if g.Player.NoFood > 3 {
kind = 2
} else {
kind = pickOne(g, g.Items.Things[:])
}
switch kind {
case 0:
cur.Kind = KindPotion
cur.Which = pickOne(g, g.Items.Potions[:])
case 1:
cur.Kind = KindScroll
cur.Which = pickOne(g, g.Items.Scrolls[:])
case 2:
g.newFoodThing(cur)
case 3:
g.newWeaponThing(cur)
case 4:
g.newArmorThing(cur)
case 5:
g.newRingThing(cur)
case 6:
cur.Kind = KindWand
cur.Which = pickOne(g, g.Items.Sticks[:])
g.fixStick(cur)
}
return cur
}
// newFoodThing rolls food, one time in ten the fruit (things.c
// new_thing).
func (g *RogueGame) newFoodThing(cur *Object) {
cur.Kind = KindFood
g.Player.NoFood = 0
if g.rnd(10) != 0 {
cur.Which = 0
} else {
cur.Which = 1
}
}
// newWeaponThing rolls a weapon, sometimes cursed or blessed (things.c
// new_thing).
func (g *RogueGame) newWeaponThing(cur *Object) {
g.initWeapon(cur, WeaponKind(pickOne(g, g.Items.Weapons[:NumWeaponTypes])))
if r := g.rnd(100); r < 10 {
cur.Flags.Set(Cursed)
cur.HPlus -= g.rnd(3) + 1
} else if r < 15 {
cur.HPlus += g.rnd(3) + 1
}
}
// newArmorThing rolls armor, sometimes cursed or blessed (things.c
// new_thing).
func (g *RogueGame) newArmorThing(cur *Object) {
cur.Kind = KindArmor
cur.Which = pickOne(g, g.Items.Armors[:])
cur.ArmorClass = g.data.aClass[cur.Which]
if r := g.rnd(100); r < 20 {
cur.Flags.Set(Cursed)
cur.ArmorClass += g.rnd(3) + 1
} else if r < 28 {
cur.ArmorClass -= g.rnd(3) + 1
}
}
// newRingThing rolls a ring, cursing the bad ones (things.c new_thing).
func (g *RogueGame) newRingThing(cur *Object) {
cur.Kind = KindRing
cur.Which = pickOne(g, g.Items.Rings[:])
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch cur.RingKind() {
case RingAddStrength, RingProtection, RingDexterity, RingIncreaseDamage:
if cur.Bonus = g.rnd(3); cur.Bonus == 0 {
cur.Bonus = -1
cur.Flags.Set(Cursed)
}
case RingAggravateMonsters, RingTeleportation:
cur.Flags.Set(Cursed)
}
}
// pickOne picks an item out of a list of possible objects using their
// cumulative probabilities (things.c pick_one).
func pickOne(g *RogueGame, info []ObjInfo) int {
i := g.rnd(100)
for idx := range info {
if i < info[idx].Prob {
return idx
}
}
return 0 // bad pick_one: C resets to the start of the table
}
// invPage is the things.c static pagination state for the discovery/
// inventory list windows (line_cnt, newpage, lastfmt/lastarg, maxlen).
type invPage struct {
lineCnt int
newpage bool
lastLine string
maxlen int
init bool
}
// discovered lists what the player has found of a certain type
// (things.c discovered).
func (g *RogueGame) discovered() {
var ch byte
for {
discList := false
if !g.Options.Terse {
g.addmsgf("for ")
}
g.addmsgf("what type")
if !g.Options.Terse {
g.addmsgf(" of object do you want a list")
}
g.msg("? (* for all)")
ch = g.readchar()
switch ch {
case Escape:
g.msg("")
return
case Potion, Scroll, Ring, Stick, '*':
discList = true
default:
if g.Options.Terse {
g.msg("Not a type")
} else {
g.msg("Please type one of %c%c%c%c (ESCAPE to quit)",
Potion, Scroll, Ring, Stick)
}
}
if discList {
break
}
}
if ch == '*' {
g.printDisc(Potion)
g.addLine("")
g.printDisc(Scroll)
g.addLine("")
g.printDisc(Ring)
g.addLine("")
g.printDisc(Stick)
g.endLine()
} else {
g.printDisc(ch)
g.endLine()
}
}
// printDisc prints what we've discovered of the given type
// (things.c print_disc).
func (g *RogueGame) printDisc(typ byte) {
var info []ObjInfo
switch typ {
case Scroll:
info = g.Items.Scrolls[:]
case Potion:
info = g.Items.Potions[:]
case Ring:
info = g.Items.Rings[:]
case Stick:
info = g.Items.Sticks[:]
}
order := make([]int, len(info))
g.setOrder(order)
obj := Object{Count: 1}
numFound := 0
for i := range info {
if info[order[i]].Know || info[order[i]].Guess != "" {
obj.Kind = objectKindForGlyph(typ)
obj.Which = order[i]
g.addLine("%s", g.inventoryName(&obj, false))
numFound++
}
}
if numFound == 0 {
g.addLine("%s", g.nothing(typ))
}
}
// setOrder shuffles the display order for the discovery list
// (things.c set_order).
func (g *RogueGame) setOrder(order []int) {
for i := range order {
order[i] = i
}
for i := len(order); i > 0; i-- {
r := g.rnd(i)
order[i-1], order[r] = order[r], order[i-1]
}
}
// addLine adds a line to the list of discoveries (things.c add_line). A
// format of exactly "\x00" is the C fmt==NULL page-flush sentinel — use
// flushLine() for that.
const flushSentinel = "\x00"
func (g *RogueGame) addLine(format string, a ...any) int {
pg := &g.invPage
isFlush := format == flushSentinel
var line string
if !isFlush {
line = fmt.Sprintf(format, a...)
}
if pg.lineCnt == 0 {
g.scr.Hw.Clear()
if g.Options.InvType == InvSlow {
g.Msgs.Mpos = 0
}
}
if g.Options.InvType == InvSlow {
return g.addLineSlow(line, isFlush)
}
g.addLinePaged(line, isFlush)
return ^Escape
}
// addLineSlow shows one discovery line as a message (the slow-inventory
// arm of things.c add_line).
func (g *RogueGame) addLineSlow(line string, isFlush bool) int {
if !isFlush && line != "" {
if g.msg("%s", line) == Escape {
return Escape
}
}
g.invPage.lineCnt++
return ^Escape
}
// addLinePaged accumulates discovery lines into the paged window,
// prompting between full pages (the windowed arm of things.c add_line).
func (g *RogueGame) addLinePaged(line string, isFlush bool) {
pg := &g.invPage
prompt := "--Press space to continue--"
if !pg.init {
pg.maxlen = len(prompt)
pg.init = true
}
if pg.lineCnt >= NumLines-1 || isFlush {
g.addLinePageBreak(prompt, isFlush)
}
if !isFlush && (pg.lineCnt != 0 || line != "") {
g.scr.Hw.MvAddStr(pg.lineCnt, 0, line)
pg.lineCnt++
if pg.maxlen < len(line) {
pg.maxlen = len(line)
}
pg.lastLine = line
}
}
// addLinePageBreak prompts at a full page and starts a fresh one
// (things.c add_line).
func (g *RogueGame) addLinePageBreak(prompt string, isFlush bool) {
pg := &g.invPage
if g.Options.InvType == InvOver && isFlush && !pg.newpage {
g.addLineOverlay(prompt)
} else {
g.scr.Hw.MvAddStr(NumLines-1, 0, prompt)
g.scr.RefreshWin(g.scr.Hw)
g.waitFor(' ')
g.scr.Hw.Clear()
g.refresh()
}
pg.newpage = true
pg.lineCnt = 0
pg.maxlen = len(prompt)
}
// addLineOverlay draws the accumulated list in a box at the top right
// of the screen, prompts, and restores what was beneath (things.c
// add_line).
func (g *RogueGame) addLineOverlay(prompt string) {
pg := &g.invPage
g.msg("")
g.refresh()
saved := NewWindow(NumLines, NumCols)
saved.CopyFrom(g.scr.Std)
lx := NumCols - pg.maxlen - 2
for y := 0; y <= pg.lineCnt; y++ {
for x := 0; x <= pg.maxlen; x++ {
g.scr.Std.MvAddCh(y, lx+x, g.scr.Hw.MvInch(y, x))
}
}
g.scr.Std.MvAddStr(pg.lineCnt, lx, prompt)
g.refresh()
g.waitFor(' ')
g.scr.Std.CopyFrom(saved)
g.refresh()
}
// flushLine is add_line(NULL): force out the accumulated page.
func (g *RogueGame) flushLine() int { return g.addLine(flushSentinel) }
// endLine ends the list of lines (things.c end_line).
func (g *RogueGame) endLine() {
pg := &g.invPage
if g.Options.InvType != InvSlow {
if pg.lineCnt == 1 && !pg.newpage {
g.Msgs.Mpos = 0
g.msg("%s", pg.lastLine)
} else {
g.flushLine()
}
}
pg.lineCnt = 0
pg.newpage = false
}
// nothing builds the "nothing found" message for a type (things.c nothing).
func (g *RogueGame) nothing(typ byte) string {
var out string
if g.Options.Terse {
out = "Nothing"
} else {
out = "Haven't discovered anything"
}
if typ != '*' {
var tystr string
switch typ {
case Potion:
tystr = potionName
case Scroll:
tystr = scrollName
case Ring:
tystr = ringName
case Stick:
tystr = "stick"
}
out += fmt.Sprintf(" about any %ss", tystr)
}
return out
}
// nameit gives the proper name to a potion, stick, or ring
// (things.c nameit).
func (g *RogueGame) nameit(pb *strings.Builder, obj *Object, typ, which string,
op *ObjInfo, prfunc func(*RogueGame, *Object) string) {
switch {
case op.Know || op.Guess != "":
if obj.Count == 1 {
fmt.Fprintf(pb, "A %s ", typ)
} else {
fmt.Fprintf(pb, "%d %ss ", obj.Count, typ)
}
if op.Know {
fmt.Fprintf(pb, "of %s%s(%s)", op.Name, prfunc(g, obj), which)
} else {
fmt.Fprintf(pb, "called %s%s(%s)", op.Guess, prfunc(g, obj), which)
}
case obj.Count == 1:
fmt.Fprintf(pb, "A%s %s %s", vowelstr(which), which, typ)
default:
fmt.Fprintf(pb, "%d %s %ss", obj.Count, which, typ)
}
}
// nullstr returns an empty string (things.c nullstr).
func nullstr(*RogueGame, *Object) string { return "" }
// prList lists possible potions, scrolls, etc. for the wizard (things.c
// pr_list).
func (g *RogueGame) prList() {
if !g.Options.Terse {
g.addmsgf("for ")
}
g.addmsgf("what type")
if !g.Options.Terse {
g.addmsgf(" of object do you want a list")
}
g.msg("? ")
ch := g.readchar()
switch ch {
case Potion:
g.prSpec(g.Items.Potions[:])
case Scroll:
g.prSpec(g.Items.Scrolls[:])
case Ring:
g.prSpec(g.Items.Rings[:])
case Stick:
g.prSpec(g.Items.Sticks[:])
case Armor:
g.prSpec(g.Items.Armors[:])
case Weapon:
g.prSpec(g.Items.Weapons[:NumWeaponTypes])
}
}
// prSpec prints a specific list of possible items to choose from
// (things.c pr_spec).
func (g *RogueGame) prSpec(info []ObjInfo) {
lastprob := 0
i := byte('0')
for idx := range info {
if i == '9'+1 {
i = 'a'
}
g.addLine("%c: %s (%d%%)", i, info[idx].Name, info[idx].Prob-lastprob)
lastprob = info[idx].Prob
i++
}
g.endLine()
}

1140
game/traps_test.go Normal file

File diff suppressed because it is too large Load Diff

418
game/types.go Normal file
View File

@@ -0,0 +1,418 @@
// Package game is a Go port of Rogue 5.4.4 ("Exploring the Dungeons of
// Doom", Michael Toy, Ken Arnold and Glenn Wichman, 1980-1999).
//
// The port is function-by-function faithful to the C sources kept in the
// repository root; ARCHITECTURE.md documents both the original program and
// the design of this port. All formerly-global state lives on the RogueGame
// type.
package game
// Maximum number of different things (rogue.h)
const (
MaxRooms = 9
MaxThings = 9
MaxObj = 9
MaxPack = 23
MaxTraps = 10
AmuletLevel = 26
NumThings = 7 // number of types of things
MaxPass = 13 // upper limit on number of passages
NumLines = 24
NumCols = 80
StatLine = NumLines - 1
BoreLevel = 50
MaxStr = 1024 // maximum length of strings
MaxLines = 32 // maximum number of screen lines used
MaxCols = 80 // maximum number of screen columns used
)
// Return values for get functions (rogue.h)
const (
Norm = 0 // normal exit
Quit = 1 // quit option setting
Minus = 2 // back up one option
)
// Inventory types (rogue.h)
const (
InvOver = 0
InvSlow = 1
InvClear = 2
)
// Things that appear on the screens (rogue.h). These byte values serve both
// as map characters and as Object.Type discriminators, exactly as in C.
const (
Passage byte = '#'
Door byte = '+'
Floor byte = '.'
PlayerCh byte = '@'
Trap byte = '^'
Stairs byte = '%'
Gold byte = '*'
Potion byte = '!'
Scroll byte = '?'
Magic byte = '$'
Food byte = ':'
Weapon byte = ')'
Armor byte = ']'
Amulet byte = ','
Ring byte = '='
Stick byte = '/'
)
// Various constants (rogue.h)
const (
HealTime = 30
HuhDuration = 20
SeeDuration = 850
HungerTime = 1300
MoreTime = 150
StomachSize = 2000
StarveTime = 850
Escape = 27
Left = 0
Right = 1
BoltLength = 6
LampDist = 3
MaxDaemons = 20
MaxInp = 50 // options.c: max string entered by the user
MaxNameLen = 40 // init.c MAXNAME: max chars in a generated scroll name
)
// Save against things (rogue.h)
const (
VsPoison = 0
VsParalyzation = 0
VsDeath = 0
VsBreath = 2
VsMagic = 3
)
// RoomFlags are the room state bits (rogue.h room flags).
type RoomFlags int16
// Room state bits (rogue.h ISDARK/ISGONE/ISMAZE).
const (
Dark RoomFlags = 1 << iota // room is dark
Gone // room is gone (a corridor)
Maze // room is a maze
)
// Has reports whether any of the given bits are set.
func (f *RoomFlags) Has(b RoomFlags) bool { return *f&b != 0 }
// Set turns the given bits on.
func (f *RoomFlags) Set(b RoomFlags) { *f |= b }
// Clear turns the given bits off.
func (f *RoomFlags) Clear(b RoomFlags) { *f &^= b }
// ObjFlags are the object state bits (rogue.h object flags).
type ObjFlags int32
// Object state bits (rogue.h).
const (
Cursed ObjFlags = 1 << iota // ISCURSED: object is cursed
Known // ISKNOW: player knows details about the object
Missile // ISMISL: object is a missile type
Stackable // ISMANY: object comes in groups
WasFound // ISFOUND (objects): seen; bit shared with creatures
Protected // ISPROT: armor is permanently protected
)
// Has reports whether any of the given bits are set.
func (f *ObjFlags) Has(b ObjFlags) bool { return *f&b != 0 }
// Set turns the given bits on.
func (f *ObjFlags) Set(b ObjFlags) { *f |= b }
// Clear turns the given bits off.
func (f *ObjFlags) Clear(b ObjFlags) { *f &^= b }
// CreatureFlags are the creature state bits (rogue.h creature flags). The
// C bit collisions are deliberate and preserved: one name of each pair
// applies to monsters, the other to the hero, and they never coexist on
// one creature.
type CreatureFlags int32
// Creature state bits (rogue.h).
const (
CanConfuse CreatureFlags = 0o000001 // CANHUH: creature can confuse
CanSeeInvisible CreatureFlags = 0o000002 // CANSEE: can see invisible creatures
Blind CreatureFlags = 0o000004 // ISBLIND: creature is blind
Cancelled CreatureFlags = 0o000010 // ISCANC: special qualities cancelled
Levitating CreatureFlags = 0o000010 // ISLEVIT: hero is levitating
Found CreatureFlags = 0o000020 // ISFOUND: creature has been seen
Greedy CreatureFlags = 0o000040 // ISGREED: creature runs to protect gold
Hasted CreatureFlags = 0o000100 // ISHASTE: creature has been hastened
Targeted CreatureFlags = 0o000200 // ISTARGET: target of an 'f' command
Held CreatureFlags = 0o000400 // ISHELD: creature has been held
Confused CreatureFlags = 0o001000 // ISHUH: creature is confused
Invisible CreatureFlags = 0o002000 // ISINVIS: creature is invisible
Mean CreatureFlags = 0o004000 // ISMEAN: wakes when player enters room
Hallucinating CreatureFlags = 0o004000 // ISHALU: hero is on acid trip
Regenerates CreatureFlags = 0o010000 // ISREGEN: creature can regenerate
Awake CreatureFlags = 0o020000 // ISRUN: creature is running at the player
SenseMonsters CreatureFlags = 0o040000 // SEEMONST: hero can detect unseen monsters
Flying CreatureFlags = 0o040000 // ISFLY: creature can fly
Slowed CreatureFlags = 0o100000 // ISSLOW: creature has been slowed
)
// Has reports whether any of the given bits are set.
func (f *CreatureFlags) Has(b CreatureFlags) bool { return *f&b != 0 }
// Set turns the given bits on.
func (f *CreatureFlags) Set(b CreatureFlags) { *f |= b }
// Clear turns the given bits off.
func (f *CreatureFlags) Clear(b CreatureFlags) { *f &^= b }
// PlaceFlags are the per-map-cell bits (rogue.h level map flags). The low
// bits double as the passage number (FPassNum) or trap kind (FTrapMask).
type PlaceFlags uint8
// Map cell bits (rogue.h).
const (
FPassage PlaceFlags = 0x80 // F_PASS: is a passageway
FSeen PlaceFlags = 0x40 // have seen this spot before
FDropped PlaceFlags = 0x20 // object was dropped here
FLocked PlaceFlags = 0x20 // door is locked
FReal PlaceFlags = 0x10 // what you see is what you get
FPassNum PlaceFlags = 0x0f // F_PNUM: passage number mask
FTrapMask PlaceFlags = 0x07 // F_TMASK: trap number mask
)
// Has reports whether any of the given bits are set.
func (f *PlaceFlags) Has(b PlaceFlags) bool { return *f&b != 0 }
// Set turns the given bits on.
func (f *PlaceFlags) Set(b PlaceFlags) { *f |= b }
// Clear turns the given bits off.
func (f *PlaceFlags) Clear(b PlaceFlags) { *f &^= b }
// TrapKind identifies a trap (rogue.h trap types). The kind is stored in
// the low bits of a map cell's PlaceFlags (FTrapMask).
type TrapKind int
// Trap kinds (rogue.h T_* constants).
const (
TrapDoor TrapKind = 0
TrapArrow TrapKind = 1
TrapSleep TrapKind = 2
TrapBear TrapKind = 3
TrapTeleport TrapKind = 4
TrapDart TrapKind = 5
TrapRust TrapKind = 6
TrapMystery TrapKind = 7
NumTrapTypes = 8
)
// PotionKind identifies a potion (rogue.h potion types).
type PotionKind int
// Potion kinds (rogue.h)
const (
PotionConfusion PotionKind = iota
PotionLSD
PotionPoison
PotionGainStrength
PotionSeeInvisible
PotionHealing
PotionDetectMonsters
PotionDetectMagic
PotionRaiseLevel
PotionExtraHealing
PotionHaste
PotionRestoreStrength
PotionBlindness
PotionLevitation
NumPotionTypes
)
// ScrollKind identifies a scroll (rogue.h scroll types).
type ScrollKind int
// Scroll kinds (rogue.h)
const (
ScrollMonsterConfusion ScrollKind = iota
ScrollMagicMapping
ScrollHoldMonster
ScrollSleep
ScrollEnchantArmor
ScrollIdentifyPotion
ScrollIdentifyScroll
ScrollIdentifyWeapon
ScrollIdentifyArmor
ScrollIdentifyRingOrStick
ScrollScareMonster
ScrollFoodDetection
ScrollTeleportation
ScrollEnchantWeapon
ScrollCreateMonster
ScrollRemoveCurse
ScrollAggravateMonsters
ScrollProtectArmor
NumScrollTypes
)
// WeaponKind identifies a weapon (rogue.h weapon types).
type WeaponKind int
// Weapon kinds (rogue.h)
const (
WeaponMace WeaponKind = iota
WeaponLongSword
WeaponBow
WeaponArrow
WeaponDagger
WeaponTwoHandedSword
WeaponDart
WeaponShuriken
WeaponSpear
WeaponFlame // fake entry for dragon breath (ick)
)
// NumWeaponTypes counts the real weapons; the flame pseudo-weapon sits
// just past them in the tables (C's MAXWEAPONS == FLAME).
const NumWeaponTypes = WeaponFlame
// ArmorKind identifies a suit of armor (rogue.h armor types).
type ArmorKind int
// Armor kinds (rogue.h)
const (
ArmorLeather ArmorKind = iota
ArmorRingMail
ArmorStuddedLeather
ArmorScaleMail
ArmorChainMail
ArmorSplintMail
ArmorBandedMail
ArmorPlateMail
NumArmorTypes
)
// RingKind identifies a ring (rogue.h ring types).
type RingKind int
// Ring kinds (rogue.h)
const (
RingProtection RingKind = iota
RingAddStrength
RingSustainStrength
RingSearching
RingSeeInvisible
RingAdornment
RingAggravateMonsters
RingDexterity
RingIncreaseDamage
RingRegeneration
RingSlowDigestion
RingTeleportation
RingStealth
RingMaintainArmor
NumRingTypes
)
// WandKind identifies a wand or staff (rogue.h rod/wand/staff types).
type WandKind int
// Wand kinds (rogue.h)
const (
WandLight WandKind = iota
WandInvisibility
WandLightning
WandFire
WandCold
WandPolymorph
WandMagicMissile
WandHasteMonster
WandSlowMonster
WandDrainLife
WandNothing
WandTeleportAway
WandTeleportTo
WandCancellation
NumWandTypes
)
// Coord is a position on the level (rogue.h coord). A value type: the C
// ce(a,b) macro is plain == here.
type Coord struct {
X, Y int
}
// Stats describes a fighting being (rogue.h struct stats).
type Stats struct {
Str int // strength (s_str; 3..31)
Exp int // experience
Lvl int // level of mastery
ArmorClass int // armor class (s_arm)
HP int // hit points (s_hpt)
Dmg DiceSpec // damage dice, e.g. "1x4/1x2"
MaxHP int
}
// Room describes a room or passage network (rogue.h struct room).
type Room struct {
Pos Coord // upper left corner
Max Coord // size of room
Gold Coord // where the gold is
GoldVal int // how much the gold is worth
Flags RoomFlags
Exits []Coord // where the exits are (r_exit[12]/r_nexits)
}
// MonsterKind is a bestiary entry (rogue.h struct monster).
type MonsterKind struct {
Name string // what to call the monster
Carry int // probability of carrying something
Flags CreatureFlags
Stats Stats // initial stats
}
// ObjInfo describes one object class: its real name, generation
// probability, score worth, and per-game identification state
// (rogue.h struct obj_info).
type ObjInfo struct {
Name string
Prob int
Worth int
Guess string
Know bool
}
// Stone is a ring stone with its worth (rogue.h STONE).
type Stone struct {
Name string
Value int
}
// CTRL maps a letter to its control character, as the C CTRL() macro.
func CTRL(c byte) byte { return c & 0o37 } //nolint:mnd // the C CTRL() mask
// distance returns the squared distance between two points (chase.c dist).
func distance(y1, x1, y2, x2 int) int {
dx := x2 - x1
dy := y2 - y1
return dx*dx + dy*dy
}
// distCp is dist_cp from chase.c.
func distCp(c1, c2 Coord) int { return distance(c1.Y, c1.X, c2.Y, c2.X) }
// sign is misc.c sign(): -1, 0 or 1.
func sign(nm int) int {
switch {
case nm < 0:
return -1
case nm > 0:
return 1
}
return 0
}

235
game/weapons.go Normal file
View File

@@ -0,0 +1,235 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import "fmt"
// weapons.c — functions for dealing with problems brought about by weapons.
const noWeapon WeaponKind = -1
// missile fires a missile in a given direction (weapons.c missile).
func (g *RogueGame) missile(ydelta, xdelta int) {
// Get which thing we are hurling
obj, ok := g.promptPackItem("throw", KindWeapon)
if !ok {
return
}
if !g.dropCheck(obj) || g.isCurrent(obj) {
return
}
obj = g.leavePack(obj, true, false)
g.doMotion(obj, ydelta, xdelta)
// AHA! Here it has hit something. If it is a wall or a door, or if
// it misses (combat) the monster, put it on the floor
if g.Level.MonsterAt(obj.Pos.Y, obj.Pos.X) == nil ||
!g.hitMonster(obj.Pos, obj) {
g.fall(obj, true)
}
}
// doMotion does the actual motion on the screen done by an object
// traveling across the room (weapons.c do_motion).
func (g *RogueGame) doMotion(obj *Object, ydelta, xdelta int) {
p := &g.Player
// Come fly with us ...
obj.Pos = p.Pos
for {
g.eraseFlight(obj, p.Pos)
// Get the new position
obj.Pos.Y += ydelta
obj.Pos.X += xdelta
ch := g.Level.VisibleChar(obj.Pos.Y, obj.Pos.X)
if !stepOk(ch) || ch == Door {
break
}
// It hasn't hit anything yet, so display it if it's alright.
if g.canSee(obj.Pos.Y, obj.Pos.X) && !g.Options.Terse {
g.mvaddch(obj.Pos.Y, obj.Pos.X, obj.Kind.Glyph())
g.refresh()
}
}
}
// eraseFlight erases a flying object from its current square, unless it
// still sits on the hero (the erase step of weapons.c do_motion).
func (g *RogueGame) eraseFlight(obj *Object, heroPos Coord) {
if obj.Pos == heroPos || !g.canSee(obj.Pos.Y, obj.Pos.X) || g.Options.Terse {
return
}
ch := g.Level.Char(obj.Pos.Y, obj.Pos.X)
if ch == Floor && !g.showFloor() {
ch = ' '
}
g.mvaddch(obj.Pos.Y, obj.Pos.X, ch)
}
// fall drops an item someplace around here (weapons.c fall).
func (g *RogueGame) fall(obj *Object, pr bool) {
if fpos, ok := g.fallpos(obj.Pos); ok {
pp := g.Level.At(fpos.Y, fpos.X)
pp.Ch = obj.Kind.Glyph()
obj.Pos = fpos
if g.canSee(fpos.Y, fpos.X) {
if pp.Monst != nil {
pp.Monst.OldCh = obj.Kind.Glyph()
} else {
g.mvaddch(fpos.Y, fpos.X, obj.Kind.Glyph())
}
}
g.Level.AddObject(obj)
return
}
if pr {
if g.HasHit {
g.endmsg()
g.HasHit = false
}
g.msg("the %s vanishes as it hits the ground",
g.Items.Weapons[obj.Which].Name)
}
}
// hitMonster checks if the missile hits the monster (weapons.c
// hit_monster).
func (g *RogueGame) hitMonster(mp Coord, obj *Object) bool {
return g.fight(mp, obj, true)
}
// wield pulls out a certain weapon (weapons.c wield).
func (g *RogueGame) wield() {
p := &g.Player
oweapon := p.CurWeapon
if !g.dropCheck(p.CurWeapon) {
p.CurWeapon = oweapon
return
}
p.CurWeapon = oweapon
obj, ok := g.promptPackItem("wield", KindWeapon)
if !ok {
g.After = false
return
}
if obj.Kind == KindArmor {
g.msg("you can't wield armor")
g.After = false
return
}
if g.isCurrent(obj) {
g.After = false
return
}
sp := g.inventoryName(obj, true)
p.CurWeapon = obj
if !g.Options.Terse {
g.addmsgf("you are now ")
}
g.msg("wielding %s (%c)", sp, obj.PackCh)
}
// weaponSetup is one row of the weapons.c init_dam[] table (see
// gameData.initWeaps).
type weaponSetup struct {
dam DiceSpec // damage when wielded
hrl DiceSpec // damage when thrown
launch WeaponKind // launching weapon
flags ObjFlags
}
// initWeapon sets up a new weapon (weapons.c init_weapon).
func (g *RogueGame) initWeapon(weap *Object, which WeaponKind) {
// init_dam[] has a row only for the real weapons: WeaponFlame (dragon
// breath) and anything past it have none. createObj rejects such a
// choice before calling here, so this arm is unreachable in practice;
// it exists so a malformed kind leaves the weapon untouched instead of
// panicking on the table read.
if which < 0 || int(which) >= int(NumWeaponTypes) {
return
}
iwp := &g.data.initWeaps[which]
weap.Kind = KindWeapon
weap.Which = int(which)
weap.Damage = iwp.dam
weap.HurlDmg = iwp.hrl
weap.Launch = iwp.launch
weap.Flags = iwp.flags
weap.HPlus = 0
weap.DPlus = 0
switch {
case which == WeaponDagger:
weap.Count = g.rnd(4) + 2
weap.Group = g.Items.Group
g.Items.Group++
case weap.Flags.Has(Stackable):
weap.Count = g.rnd(8) + 8
weap.Group = g.Items.Group
g.Items.Group++
default:
weap.Count = 1
weap.Group = 0
}
}
// num formats a hit/damage or armor bonus string (weapons.c num).
func num(n1, n2 int, typ byte) string {
out := fmt.Sprintf("%+d", n1)
if typ == Weapon {
out += fmt.Sprintf(",%+d", n2)
}
return out
}
// fallpos picks a random empty position around (pos) for a dropped item
// (weapons.c fallpos).
func (g *RogueGame) fallpos(pos Coord) (Coord, bool) {
var newpos Coord
cnt := 0
for y := pos.Y - 1; y <= pos.Y+1; y++ {
for x := pos.X - 1; x <= pos.X+1; x++ {
// check to make certain the spot is empty, if it is, put the
// object there, set it in the level list and re-draw the room
// if he can see it
if (y == g.Player.Pos.Y && x == g.Player.Pos.X) ||
y < 0 || x < 0 {
continue
}
ch := g.Level.Char(y, x)
if ch == Floor || ch == Passage {
if cnt++; g.rnd(cnt) == 0 {
newpos.Y = y
newpos.X = x
}
}
}
}
return newpos, cnt != 0
}

247
game/wizard.go Normal file
View File

@@ -0,0 +1,247 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// wizard.c — special wizard commands, some of which are also non-wizard
// commands under strange circumstances. The DES password check is not
// ported: wizard mode is enabled by configuration instead.
// createObj is the wizard command for getting anything he wants (wizard.c
// create_obj).
func (g *RogueGame) createObj() {
obj := newObject()
g.msg("type of item: ")
obj.Kind = objectKindForGlyph(g.readchar())
g.Msgs.Mpos = 0
g.msg("which %c do you want? (0-f)", obj.Kind.Glyph())
ch := g.readchar()
if isDigit(ch) {
obj.Which = int(ch - '0')
} else {
obj.Which = int(ch-'a') + 10
}
obj.Group = 0
obj.Count = 1
g.Msgs.Mpos = 0
// Deliberate divergence from 5.4.4: C stored this nibble unchecked, so
// 'a'-'f' indexed straight past the ends of the per-kind static
// tables. Input outside '0'-'9' and 'a'-'f' overshoots much further:
// readchar returns a byte, so ch-'a' above is byte arithmetic and
// wraps instead of going negative ('A' gives 234, '!' gives 202).
// Reading past a static array was undefined behavior C happened to
// survive by picking up adjacent memory; in Go it is a panic that
// kills the process with the terminal still in raw mode. C had no
// defined behavior here to be faithful to, so the choice is rejected
// outright rather than emulating a garbage read. The check precedes
// every rnd() call below, so the RNG sequence is untouched either way.
if !obj.wizardCanCreate() {
g.msg("there is no such %s", obj.Kind)
return
}
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.Kind {
case KindWeapon, KindArmor:
g.createWeaponArmor(obj)
case KindRing:
g.createRing(obj)
case KindWand:
g.fixStick(obj)
case KindGold:
g.msg("how much?")
buf := ""
if g.getStr(&buf, g.scr.Std) == Norm {
obj.GoldValue = cAtoi(buf)
}
}
g.addPack(obj, false)
}
// createWeaponArmor sets up a wizard-created weapon or armor with an
// optional blessing (the weapon/armor arm of wizard.c create_obj).
func (g *RogueGame) createWeaponArmor(obj *Object) {
g.msg("blessing? (+,-,n)")
bless := g.readchar()
g.Msgs.Mpos = 0
if bless == '-' {
obj.Flags.Set(Cursed)
}
if obj.Kind == KindWeapon {
g.initWeapon(obj, WeaponKind(obj.Which))
if bless == '-' {
obj.HPlus -= g.rnd(3) + 1
}
if bless == '+' {
obj.HPlus += g.rnd(3) + 1
}
return
}
obj.ArmorClass = g.data.armorClass(obj.Which)
if bless == '-' {
obj.ArmorClass += g.rnd(3) + 1
}
if bless == '+' {
obj.ArmorClass -= g.rnd(3) + 1
}
}
// createRing sets up a wizard-created ring, prompting for a bonus on
// the bonus rings (the ring arm of wizard.c create_obj).
func (g *RogueGame) createRing(obj *Object) {
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.RingKind() {
case RingProtection, RingAddStrength, RingDexterity, RingIncreaseDamage:
g.msg("blessing? (+,-,n)")
bless := g.readchar()
g.Msgs.Mpos = 0
if bless == '-' {
obj.Flags.Set(Cursed)
obj.Bonus = -1
} else {
obj.Bonus = g.rnd(2) + 1
}
case RingAggravateMonsters, RingTeleportation:
obj.Flags.Set(Cursed)
}
}
// showMap prints out the whole map for the wizard (wizard.c show_map).
func (g *RogueGame) showMap() {
hw := g.scr.Hw
hw.Clear()
for y := 1; y < NumLines-1; y++ {
for x := range NumCols {
isReal := g.Level.FlagsAt(y, x).Has(FReal)
if !isReal {
hw.Standout(true)
}
hw.MvAddCh(y, x, g.Level.Char(y, x))
if !isReal {
hw.Standout(false)
}
}
}
g.showWin("---More (level map)---")
}
// whatis identifies what a certain object is (wizard.c whatis).
func (g *RogueGame) whatis(insist bool, kind ObjectKind) {
p := &g.Player
if len(p.Pack) == 0 {
g.msg("you don't have anything in your pack to identify")
return
}
obj, ok := g.whatisPick(insist, kind)
if !ok {
return
}
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.Kind {
case KindScroll:
setKnow(obj, g.Items.Scrolls[:])
case KindPotion:
setKnow(obj, g.Items.Potions[:])
case KindWand:
setKnow(obj, g.Items.Sticks[:])
case KindWeapon, KindArmor:
obj.Flags.Set(Known)
case KindRing:
setKnow(obj, g.Items.Rings[:])
}
g.msg("%s", g.inventoryName(obj, false))
}
// whatisPick prompts for the item to identify, re-asking until a
// matching one is chosen when insist is set; ok is false when the
// player gives up (the prompt loop of wizard.c whatis).
func (g *RogueGame) whatisPick(insist bool, kind ObjectKind) (*Object, bool) {
for {
obj, _ := g.promptPackItem("identify", kind)
if !insist {
return obj, obj != nil
}
if g.NObjs == 0 {
return nil, false
}
if obj == nil {
g.msg("you must identify something")
continue
}
if !matchesFilter(kind, obj) {
g.msg("you must identify a %s", kind)
continue
}
return obj, true
}
}
// setKnow sets things up when we really know what a thing is (wizard.c
// set_know).
func setKnow(obj *Object, info []ObjInfo) {
info[obj.Which].Know = true
obj.Flags.Set(Known)
info[obj.Which].Guess = ""
}
// The C type_name()/tlist table is gone: ObjectKind.String() carries the
// same vocabulary.
// teleport bamfs the hero someplace else (wizard.c teleport).
func (g *RogueGame) teleport() {
p := &g.Player
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorAt())
c, _ := g.findFloor(true)
if g.roomIn(c) != p.Room {
g.leaveRoom(p.Pos)
p.Pos = c
g.enterRoom(p.Pos)
} else {
p.Pos = c
g.look(true)
}
g.mvaddch(p.Pos.Y, p.Pos.X, PlayerCh)
// turn off ISHELD in case teleportation was done while fighting a
// Flytrap
if p.On(Held) {
p.Flags.Clear(Held)
p.VfHit = 0
g.Monsters['F'-'A'].Stats.Dmg = dice("000x0")
}
g.NoMove = 0
g.Count = 0
g.Running = false
g.flushType()
}

1493
game/wizard_test.go Normal file

File diff suppressed because it is too large Load Diff

14
go.mod Normal file
View File

@@ -0,0 +1,14 @@
module git.eeqj.de/sneak/rgoue
go 1.25.7
require github.com/gdamore/tcell/v2 v2.13.10
require (
github.com/gdamore/encoding v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
)

45
go.sum Normal file
View File

@@ -0,0 +1,45 @@
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=
github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

447
init.c
View File

@@ -1,447 +0,0 @@
/*
* global variable initializaton
*
* @(#)init.c 4.31 (Berkeley) 02/05/99
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <stdlib.h>
#include <curses.h>
#include <ctype.h>
#include <string.h>
#include "rogue.h"
/*
* init_player:
* Roll her up
*/
void
init_player()
{
register THING *obj;
pstats = max_stats;
food_left = HUNGERTIME;
/*
* Give him some food
*/
obj = new_item();
obj->o_type = FOOD;
obj->o_count = 1;
add_pack(obj, TRUE);
/*
* And his suit of armor
*/
obj = new_item();
obj->o_type = ARMOR;
obj->o_which = RING_MAIL;
obj->o_arm = a_class[RING_MAIL] - 1;
obj->o_flags |= ISKNOW;
obj->o_count = 1;
cur_armor = obj;
add_pack(obj, TRUE);
/*
* Give him his weaponry. First a mace.
*/
obj = new_item();
init_weapon(obj, MACE);
obj->o_hplus = 1;
obj->o_dplus = 1;
obj->o_flags |= ISKNOW;
add_pack(obj, TRUE);
cur_weapon = obj;
/*
* Now a +1 bow
*/
obj = new_item();
init_weapon(obj, BOW);
obj->o_hplus = 1;
obj->o_flags |= ISKNOW;
add_pack(obj, TRUE);
/*
* Now some arrows
*/
obj = new_item();
init_weapon(obj, ARROW);
obj->o_count = rnd(15) + 25;
obj->o_flags |= ISKNOW;
add_pack(obj, TRUE);
}
/*
* Contains defintions and functions for dealing with things like
* potions and scrolls
*/
char *rainbow[] = {
"amber",
"aquamarine",
"black",
"blue",
"brown",
"clear",
"crimson",
"cyan",
"ecru",
"gold",
"green",
"grey",
"magenta",
"orange",
"pink",
"plaid",
"purple",
"red",
"silver",
"tan",
"tangerine",
"topaz",
"turquoise",
"vermilion",
"violet",
"white",
"yellow",
};
#define NCOLORS (sizeof rainbow / sizeof (char *))
int cNCOLORS = NCOLORS;
static char *sylls[] = {
"a", "ab", "ag", "aks", "ala", "an", "app", "arg", "arze", "ash",
"bek", "bie", "bit", "bjor", "blu", "bot", "bu", "byt", "comp",
"con", "cos", "cre", "dalf", "dan", "den", "do", "e", "eep", "el",
"eng", "er", "ere", "erk", "esh", "evs", "fa", "fid", "fri", "fu",
"gan", "gar", "glen", "gop", "gre", "ha", "hyd", "i", "ing", "ip",
"ish", "it", "ite", "iv", "jo", "kho", "kli", "klis", "la", "lech",
"mar", "me", "mi", "mic", "mik", "mon", "mung", "mur", "nej",
"nelg", "nep", "ner", "nes", "nes", "nih", "nin", "o", "od", "ood",
"org", "orn", "ox", "oxy", "pay", "ple", "plu", "po", "pot",
"prok", "re", "rea", "rhov", "ri", "ro", "rog", "rok", "rol", "sa",
"san", "sat", "sef", "seh", "shu", "ski", "sna", "sne", "snik",
"sno", "so", "sol", "sri", "sta", "sun", "ta", "tab", "tem",
"ther", "ti", "tox", "trol", "tue", "turs", "u", "ulk", "um", "un",
"uni", "ur", "val", "viv", "vly", "vom", "wah", "wed", "werg",
"wex", "whon", "wun", "xo", "y", "yot", "yu", "zant", "zeb", "zim",
"zok", "zon", "zum",
};
STONE stones[] = {
{ "agate", 25},
{ "alexandrite", 40},
{ "amethyst", 50},
{ "carnelian", 40},
{ "diamond", 300},
{ "emerald", 300},
{ "germanium", 225},
{ "granite", 5},
{ "garnet", 50},
{ "jade", 150},
{ "kryptonite", 300},
{ "lapis lazuli", 50},
{ "moonstone", 50},
{ "obsidian", 15},
{ "onyx", 60},
{ "opal", 200},
{ "pearl", 220},
{ "peridot", 63},
{ "ruby", 350},
{ "sapphire", 285},
{ "stibotantalite", 200},
{ "tiger eye", 50},
{ "topaz", 60},
{ "turquoise", 70},
{ "taaffeite", 300},
{ "zircon", 80},
};
#define NSTONES (sizeof stones / sizeof (STONE))
int cNSTONES = NSTONES;
char *wood[] = {
"avocado wood",
"balsa",
"bamboo",
"banyan",
"birch",
"cedar",
"cherry",
"cinnibar",
"cypress",
"dogwood",
"driftwood",
"ebony",
"elm",
"eucalyptus",
"fall",
"hemlock",
"holly",
"ironwood",
"kukui wood",
"mahogany",
"manzanita",
"maple",
"oaken",
"persimmon wood",
"pecan",
"pine",
"poplar",
"redwood",
"rosewood",
"spruce",
"teak",
"walnut",
"zebrawood",
};
#define NWOOD (sizeof wood / sizeof (char *))
int cNWOOD = NWOOD;
char *metal[] = {
"aluminum",
"beryllium",
"bone",
"brass",
"bronze",
"copper",
"electrum",
"gold",
"iron",
"lead",
"magnesium",
"mercury",
"nickel",
"pewter",
"platinum",
"steel",
"silver",
"silicon",
"tin",
"titanium",
"tungsten",
"zinc",
};
#define NMETAL (sizeof metal / sizeof (char *))
int cNMETAL = NMETAL;
#define MAX3(a,b,c) (a > b ? (a > c ? a : c) : (b > c ? b : c))
static bool used[MAX3(NCOLORS, NSTONES, NWOOD)];
/*
* init_colors:
* Initialize the potion color scheme for this time
*/
void
init_colors()
{
register int i, j;
for (i = 0; i < NCOLORS; i++)
used[i] = FALSE;
for (i = 0; i < MAXPOTIONS; i++)
{
do
j = rnd(NCOLORS);
until (!used[j]);
used[j] = TRUE;
p_colors[i] = rainbow[j];
}
}
/*
* init_names:
* Generate the names of the various scrolls
*/
#define MAXNAME 40 /* Max number of characters in a name */
void
init_names()
{
register int nsyl;
register char *cp, *sp;
register int i, nwords;
for (i = 0; i < MAXSCROLLS; i++)
{
cp = prbuf;
nwords = rnd(3) + 2;
while (nwords--)
{
nsyl = rnd(3) + 1;
while (nsyl--)
{
sp = sylls[rnd((sizeof sylls) / (sizeof (char *)))];
if (&cp[strlen(sp)] > &prbuf[MAXNAME])
break;
while (*sp)
*cp++ = *sp++;
}
*cp++ = ' ';
}
*--cp = '\0';
s_names[i] = (char *) malloc((unsigned) strlen(prbuf)+1);
strcpy(s_names[i], prbuf);
}
}
/*
* init_stones:
* Initialize the ring stone setting scheme for this time
*/
void
init_stones()
{
register int i, j;
for (i = 0; i < NSTONES; i++)
used[i] = FALSE;
for (i = 0; i < MAXRINGS; i++)
{
do
j = rnd(NSTONES);
until (!used[j]);
used[j] = TRUE;
r_stones[i] = stones[j].st_name;
ring_info[i].oi_worth += stones[j].st_value;
}
}
/*
* init_materials:
* Initialize the construction materials for wands and staffs
*/
void
init_materials()
{
register int i, j;
register char *str;
static bool metused[NMETAL];
for (i = 0; i < NWOOD; i++)
used[i] = FALSE;
for (i = 0; i < NMETAL; i++)
metused[i] = FALSE;
for (i = 0; i < MAXSTICKS; i++)
{
for (;;)
if (rnd(2) == 0)
{
j = rnd(NMETAL);
if (!metused[j])
{
ws_type[i] = "wand";
str = metal[j];
metused[j] = TRUE;
break;
}
}
else
{
j = rnd(NWOOD);
if (!used[j])
{
ws_type[i] = "staff";
str = wood[j];
used[j] = TRUE;
break;
}
}
ws_made[i] = str;
}
}
#ifdef MASTER
# define NT NUMTHINGS, "things"
# define MP MAXPOTIONS, "potions"
# define MS MAXSCROLLS, "scrolls"
# define MR MAXRINGS, "rings"
# define MWS MAXSTICKS, "sticks"
# define MW MAXWEAPONS, "weapons"
# define MA MAXARMORS, "armor"
#else
# define NT NUMTHINGS
# define MP MAXPOTIONS
# define MS MAXSCROLLS
# define MR MAXRINGS
# define MWS MAXSTICKS
# define MW MAXWEAPONS
# define MA MAXARMORS
#endif
/*
* sumprobs:
* Sum up the probabilities for items appearing
*/
void
sumprobs(struct obj_info *info, int bound
#ifdef MASTER
, char *name
#endif
)
{
#ifdef MASTER
struct obj_info *start = info;
#endif
struct obj_info *endp;
endp = info + bound;
while (++info < endp)
info->oi_prob += (info - 1)->oi_prob;
#ifdef MASTER
badcheck(name, start, bound);
#endif
}
/*
* init_probs:
* Initialize the probabilities for the various items
*/
void
init_probs()
{
sumprobs(things, NT);
sumprobs(pot_info, MP);
sumprobs(scr_info, MS);
sumprobs(ring_info, MR);
sumprobs(ws_info, MWS);
sumprobs(weap_info, MW);
sumprobs(arm_info, MA);
}
#ifdef MASTER
/*
* badcheck:
* Check to see if a series of probabilities sums to 100
*/
void
badcheck(char *name, struct obj_info *info, int bound)
{
register struct obj_info *end;
if (info[bound - 1].oi_prob == 100)
return;
printf("\nBad percentages for %s (bound = %d):\n", name, bound);
for (end = &info[bound]; info < end; info++)
printf("%3d%% %s\n", info->oi_prob, info->oi_name);
printf("[hit RETURN to continue]");
fflush(stdout);
while (getchar() != '\n')
continue;
}
#endif
/*
* pick_color:
* If he is halucinating, pick a random color name and return it,
* otherwise return the given color.
*/
char *
pick_color(char *col)
{
return (on(player, ISHALU) ? rainbow[rnd(NCOLORS)] : col);
}

View File

@@ -1,323 +0,0 @@
#!/bin/sh
# install - install a program, script, or datafile
scriptversion=2005-05-14.22
# This originates from X11R5 (mit/util/scripts/install.sh), which was
# later released in X11R6 (xc/config/util/install.sh) with the
# following copyright and license.
#
# Copyright (C) 1994 X Consortium
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# X CONSORTIUM BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
# AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNEC-
# TION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#
# Except as contained in this notice, the name of the X Consortium shall not
# be used in advertising or otherwise to promote the sale, use or other deal-
# ings in this Software without prior written authorization from the X Consor-
# tium.
#
#
# FSF changes to this file are in the public domain.
#
# Calling this script install-sh is preferred over install.sh, to prevent
# `make' implicit rules from creating a file called install from it
# when there is no Makefile.
#
# This script is compatible with the BSD install script, but was written
# from scratch. It can only install one file at a time, a restriction
# shared with many OS's install programs.
# set DOITPROG to echo to test this script
# Don't use :- since 4.3BSD and earlier shells don't like it.
doit="${DOITPROG-}"
# put in absolute paths if you don't have them in your path; or use env. vars.
mvprog="${MVPROG-mv}"
cpprog="${CPPROG-cp}"
chmodprog="${CHMODPROG-chmod}"
chownprog="${CHOWNPROG-chown}"
chgrpprog="${CHGRPPROG-chgrp}"
stripprog="${STRIPPROG-strip}"
rmprog="${RMPROG-rm}"
mkdirprog="${MKDIRPROG-mkdir}"
chmodcmd="$chmodprog 0755"
chowncmd=
chgrpcmd=
stripcmd=
rmcmd="$rmprog -f"
mvcmd="$mvprog"
src=
dst=
dir_arg=
dstarg=
no_target_directory=
usage="Usage: $0 [OPTION]... [-T] SRCFILE DSTFILE
or: $0 [OPTION]... SRCFILES... DIRECTORY
or: $0 [OPTION]... -t DIRECTORY SRCFILES...
or: $0 [OPTION]... -d DIRECTORIES...
In the 1st form, copy SRCFILE to DSTFILE.
In the 2nd and 3rd, copy all SRCFILES to DIRECTORY.
In the 4th, create DIRECTORIES.
Options:
-c (ignored)
-d create directories instead of installing files.
-g GROUP $chgrpprog installed files to GROUP.
-m MODE $chmodprog installed files to MODE.
-o USER $chownprog installed files to USER.
-s $stripprog installed files.
-t DIRECTORY install into DIRECTORY.
-T report an error if DSTFILE is a directory.
--help display this help and exit.
--version display version info and exit.
Environment variables override the default commands:
CHGRPPROG CHMODPROG CHOWNPROG CPPROG MKDIRPROG MVPROG RMPROG STRIPPROG
"
while test -n "$1"; do
case $1 in
-c) shift
continue;;
-d) dir_arg=true
shift
continue;;
-g) chgrpcmd="$chgrpprog $2"
shift
shift
continue;;
--help) echo "$usage"; exit $?;;
-m) chmodcmd="$chmodprog $2"
shift
shift
continue;;
-o) chowncmd="$chownprog $2"
shift
shift
continue;;
-s) stripcmd=$stripprog
shift
continue;;
-t) dstarg=$2
shift
shift
continue;;
-T) no_target_directory=true
shift
continue;;
--version) echo "$0 $scriptversion"; exit $?;;
*) # When -d is used, all remaining arguments are directories to create.
# When -t is used, the destination is already specified.
test -n "$dir_arg$dstarg" && break
# Otherwise, the last argument is the destination. Remove it from $@.
for arg
do
if test -n "$dstarg"; then
# $@ is not empty: it contains at least $arg.
set fnord "$@" "$dstarg"
shift # fnord
fi
shift # arg
dstarg=$arg
done
break;;
esac
done
if test -z "$1"; then
if test -z "$dir_arg"; then
echo "$0: no input file specified." >&2
exit 1
fi
# It's OK to call `install-sh -d' without argument.
# This can happen when creating conditional directories.
exit 0
fi
for src
do
# Protect names starting with `-'.
case $src in
-*) src=./$src ;;
esac
if test -n "$dir_arg"; then
dst=$src
src=
if test -d "$dst"; then
mkdircmd=:
chmodcmd=
else
mkdircmd=$mkdirprog
fi
else
# Waiting for this to be detected by the "$cpprog $src $dsttmp" command
# might cause directories to be created, which would be especially bad
# if $src (and thus $dsttmp) contains '*'.
if test ! -f "$src" && test ! -d "$src"; then
echo "$0: $src does not exist." >&2
exit 1
fi
if test -z "$dstarg"; then
echo "$0: no destination specified." >&2
exit 1
fi
dst=$dstarg
# Protect names starting with `-'.
case $dst in
-*) dst=./$dst ;;
esac
# If destination is a directory, append the input filename; won't work
# if double slashes aren't ignored.
if test -d "$dst"; then
if test -n "$no_target_directory"; then
echo "$0: $dstarg: Is a directory" >&2
exit 1
fi
dst=$dst/`basename "$src"`
fi
fi
# This sed command emulates the dirname command.
dstdir=`echo "$dst" | sed -e 's,/*$,,;s,[^/]*$,,;s,/*$,,;s,^$,.,'`
# Make sure that the destination directory exists.
# Skip lots of stat calls in the usual case.
if test ! -d "$dstdir"; then
defaultIFS='
'
IFS="${IFS-$defaultIFS}"
oIFS=$IFS
# Some sh's can't handle IFS=/ for some reason.
IFS='%'
set x `echo "$dstdir" | sed -e 's@/@%@g' -e 's@^%@/@'`
shift
IFS=$oIFS
pathcomp=
while test $# -ne 0 ; do
pathcomp=$pathcomp$1
shift
if test ! -d "$pathcomp"; then
$mkdirprog "$pathcomp"
# mkdir can fail with a `File exist' error in case several
# install-sh are creating the directory concurrently. This
# is OK.
test -d "$pathcomp" || exit
fi
pathcomp=$pathcomp/
done
fi
if test -n "$dir_arg"; then
$doit $mkdircmd "$dst" \
&& { test -z "$chowncmd" || $doit $chowncmd "$dst"; } \
&& { test -z "$chgrpcmd" || $doit $chgrpcmd "$dst"; } \
&& { test -z "$stripcmd" || $doit $stripcmd "$dst"; } \
&& { test -z "$chmodcmd" || $doit $chmodcmd "$dst"; }
else
dstfile=`basename "$dst"`
# Make a couple of temp file names in the proper directory.
dsttmp=$dstdir/_inst.$$_
rmtmp=$dstdir/_rm.$$_
# Trap to clean up those temp files at exit.
trap 'ret=$?; rm -f "$dsttmp" "$rmtmp" && exit $ret' 0
trap '(exit $?); exit' 1 2 13 15
# Copy the file name to the temp name.
$doit $cpprog "$src" "$dsttmp" &&
# and set any options; do chmod last to preserve setuid bits.
#
# If any of these fail, we abort the whole thing. If we want to
# ignore errors from any of these, just make sure not to ignore
# errors from the above "$doit $cpprog $src $dsttmp" command.
#
{ test -z "$chowncmd" || $doit $chowncmd "$dsttmp"; } \
&& { test -z "$chgrpcmd" || $doit $chgrpcmd "$dsttmp"; } \
&& { test -z "$stripcmd" || $doit $stripcmd "$dsttmp"; } \
&& { test -z "$chmodcmd" || $doit $chmodcmd "$dsttmp"; } &&
# Now rename the file to the real destination.
{ $doit $mvcmd -f "$dsttmp" "$dstdir/$dstfile" 2>/dev/null \
|| {
# The rename failed, perhaps because mv can't rename something else
# to itself, or perhaps because mv is so ancient that it does not
# support -f.
# Now remove or move aside any old file at destination location.
# We try this two ways since rm can't unlink itself on some
# systems and the destination file might be busy for other
# reasons. In this case, the final cleanup might fail but the new
# file should still install successfully.
{
if test -f "$dstdir/$dstfile"; then
$doit $rmcmd -f "$dstdir/$dstfile" 2>/dev/null \
|| $doit $mvcmd -f "$dstdir/$dstfile" "$rmtmp" 2>/dev/null \
|| {
echo "$0: cannot unlink or rename $dstdir/$dstfile" >&2
(exit 1); exit 1
}
else
:
fi
} &&
# Now rename the file to the real destination.
$doit $mvcmd "$dsttmp" "$dstdir/$dstfile"
}
}
fi || { (exit 1); exit 1; }
done
# The final little trick to "correctly" pass the exit status to the exit trap.
{
(exit 0); exit 0
}
# Local variables:
# eval: (add-hook 'write-file-hooks 'time-stamp)
# time-stamp-start: "scriptversion="
# time-stamp-format: "%:y-%02m-%02d.%02H"
# time-stamp-end: "$"
# End:

277
io.c
View File

@@ -1,277 +0,0 @@
/*
* Various input/output functions
*
* @(#)io.c 4.32 (Berkeley) 02/05/99
*/
#include <stdarg.h>
#include <curses.h>
#include <ctype.h>
#include <string.h>
#include "rogue.h"
/*
* msg:
* Display a message at the top of the screen.
*/
#define MAXMSG (NUMCOLS - sizeof "--More--")
static char msgbuf[2*MAXMSG+1];
static int newpos = 0;
/* VARARGS1 */
int
msg(char *fmt, ...)
{
va_list args;
/*
* if the string is "", just clear the line
*/
if (*fmt == '\0')
{
move(0, 0);
clrtoeol();
mpos = 0;
return ~ESCAPE;
}
/*
* otherwise add to the message and flush it out
*/
va_start(args, fmt);
doadd(fmt, args);
va_end(args);
return endmsg();
}
/*
* addmsg:
* Add things to the current message
*/
/* VARARGS1 */
void
addmsg(char *fmt, ...)
{
va_list args;
va_start(args, fmt);
doadd(fmt, args);
va_end(args);
}
/*
* endmsg:
* Display a new msg (giving him a chance to see the previous one
* if it is up there with the --More--)
*/
int
endmsg()
{
char ch;
if (save_msg)
strcpy(huh, msgbuf);
if (mpos)
{
look(FALSE);
mvaddstr(0, mpos, "--More--");
refresh();
if (!msg_esc)
wait_for(' ');
else
{
while ((ch = readchar()) != ' ')
if (ch == ESCAPE)
{
msgbuf[0] = '\0';
mpos = 0;
newpos = 0;
msgbuf[0] = '\0';
return ESCAPE;
}
}
}
/*
* All messages should start with uppercase, except ones that
* start with a pack addressing character
*/
if (islower(msgbuf[0]) && !lower_msg && msgbuf[1] != ')')
msgbuf[0] = (char) toupper(msgbuf[0]);
mvaddstr(0, 0, msgbuf);
clrtoeol();
mpos = newpos;
newpos = 0;
msgbuf[0] = '\0';
refresh();
return ~ESCAPE;
}
/*
* doadd:
* Perform an add onto the message buffer
*/
void
doadd(char *fmt, va_list args)
{
static char buf[MAXSTR];
/*
* Do the printf into buf
*/
vsprintf(buf, fmt, args);
if (strlen(buf) + newpos >= MAXMSG)
endmsg();
strcat(msgbuf, buf);
newpos = (int) strlen(msgbuf);
}
/*
* step_ok:
* Returns true if it is ok to step on ch
*/
int
step_ok(int ch)
{
switch (ch)
{
case ' ':
case '|':
case '-':
return FALSE;
default:
return (!isalpha(ch));
}
}
/*
* readchar:
* Reads and returns a character, checking for gross input errors
*/
char
readchar()
{
char ch;
ch = (char) md_readchar();
if (ch == 3)
{
quit(0);
return(27);
}
return(ch);
}
/*
* status:
* Display the important stats line. Keep the cursor where it was.
*/
void
status()
{
register int oy, ox, temp;
static int hpwidth = 0;
static int s_hungry = 0;
static int s_lvl = 0;
static int s_pur = -1;
static int s_hp = 0;
static int s_arm = 0;
static str_t s_str = 0;
static int s_exp = 0;
static char *state_name[] =
{
"", "Hungry", "Weak", "Faint"
};
/*
* If nothing has changed since the last status, don't
* bother.
*/
temp = (cur_armor != NULL ? cur_armor->o_arm : pstats.s_arm);
if (s_hp == pstats.s_hpt && s_exp == pstats.s_exp && s_pur == purse
&& s_arm == temp && s_str == pstats.s_str && s_lvl == level
&& s_hungry == hungry_state
&& !stat_msg
)
return;
s_arm = temp;
getyx(stdscr, oy, ox);
if (s_hp != max_hp)
{
temp = max_hp;
s_hp = max_hp;
for (hpwidth = 0; temp; hpwidth++)
temp /= 10;
}
/*
* Save current status
*/
s_lvl = level;
s_pur = purse;
s_hp = pstats.s_hpt;
s_str = pstats.s_str;
s_exp = pstats.s_exp;
s_hungry = hungry_state;
if (stat_msg)
{
move(0, 0);
msg("Level: %d Gold: %-5d Hp: %*d(%*d) Str: %2d(%d) Arm: %-2d Exp: %d/%ld %s",
level, purse, hpwidth, pstats.s_hpt, hpwidth, max_hp, pstats.s_str,
max_stats.s_str, 10 - s_arm, pstats.s_lvl, pstats.s_exp,
state_name[hungry_state]);
}
else
{
move(STATLINE, 0);
printw("Level: %d Gold: %-5d Hp: %*d(%*d) Str: %2d(%d) Arm: %-2d Exp: %d/%d %s",
level, purse, hpwidth, pstats.s_hpt, hpwidth, max_hp, pstats.s_str,
max_stats.s_str, 10 - s_arm, pstats.s_lvl, pstats.s_exp,
state_name[hungry_state]);
}
clrtoeol();
move(oy, ox);
}
/*
* wait_for
* Sit around until the guy types the right key
*/
void
wait_for(int ch)
{
register char c;
if (ch == '\n')
while ((c = readchar()) != '\n' && c != '\r')
continue;
else
while (readchar() != ch)
continue;
}
/*
* show_win:
* Function used to display a window and wait before returning
*/
void
show_win(char *message)
{
WINDOW *win;
win = hw;
wmove(win, 0, 0);
waddstr(win, message);
touchwin(win);
wmove(win, hero.y, hero.x);
wrefresh(win);
wait_for(' ');
clearok(curscr, TRUE);
touchwin(stdscr);
}

113
list.c
View File

@@ -1,113 +0,0 @@
/*
* Functions for dealing with linked lists of goodies
*
* @(#)list.c 4.12 (Berkeley) 02/05/99
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <stdlib.h>
#include <curses.h>
#include "rogue.h"
#ifdef MASTER
int total = 0; /* total dynamic memory bytes */
#endif
/*
* detach:
* takes an item out of whatever linked list it might be in
*/
void
_detach(THING **list, THING *item)
{
if (*list == item)
*list = next(item);
if (prev(item) != NULL)
item->l_prev->l_next = next(item);
if (next(item) != NULL)
item->l_next->l_prev = prev(item);
item->l_next = NULL;
item->l_prev = NULL;
}
/*
* _attach:
* add an item to the head of a list
*/
void
_attach(THING **list, THING *item)
{
if (*list != NULL)
{
item->l_next = *list;
(*list)->l_prev = item;
item->l_prev = NULL;
}
else
{
item->l_next = NULL;
item->l_prev = NULL;
}
*list = item;
}
/*
* _free_list:
* Throw the whole blamed thing away
*/
void
_free_list(THING **ptr)
{
THING *item;
while (*ptr != NULL)
{
item = *ptr;
*ptr = next(item);
discard(item);
}
}
/*
* discard:
* Free up an item
*/
void
discard(THING *item)
{
#ifdef MASTER
total--;
#endif
free((char *) item);
}
/*
* new_item
* Get a new item with a specified size
*/
THING *
new_item()
{
THING *item;
#ifdef MASTER
if ((item = calloc(1, sizeof *item)) == NULL)
msg("ran out of memory after %d items", total);
else
total++;
#else
item = calloc(1, sizeof *item);
#endif
item->l_next = NULL;
item->l_prev = NULL;
return item;
}

View File

@@ -1,457 +0,0 @@
/*
* Various installation dependent routines
*
* @(#)mach_dep.c 4.37 (Berkeley) 05/23/83
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
/*
* The various tuneable defines are:
*
* SCOREFILE Where/if the score file should live.
* ALLSCORES Score file is top ten scores, not top ten
* players. This is only useful when only a few
* people will be playing; otherwise the score file
* gets hogged by just a few people.
* NUMSCORES Number of scores in the score file (default 10).
* NUMNAME String version of NUMSCORES (first character
* should be capitalized) (default "Ten").
* MAXLOAD What (if any) the maximum load average should be
* when people are playing. Since it is divided
* by 10, to specify a load limit of 4.0, MAXLOAD
* should be "40". If defined, then
* LOADAV Should it use it's own routine to get
* the load average?
* NAMELIST If so, where does the system namelist
* hide?
* MAXUSERS What (if any) the maximum user count should be
* when people are playing. If defined, then
* UCOUNT Should it use it's own routine to count
* users?
* UTMP If so, where does the user list hide?
* CHECKTIME How often/if it should check during the game
* for high load average.
*/
#include <signal.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <limits.h>
#include <string.h>
#include <fcntl.h>
#include <errno.h>
#include <time.h>
#include <curses.h>
#include "extern.h"
#define NOOP(x) (x += 0)
# ifndef NUMSCORES
# define NUMSCORES 10
# define NUMNAME "Ten"
# endif
unsigned int numscores = NUMSCORES;
char *Numname = NUMNAME;
# ifdef ALLSCORES
bool allscore = TRUE;
# else /* ALLSCORES */
bool allscore = FALSE;
# endif /* ALLSCORES */
#ifdef CHECKTIME
static int num_checks; /* times we've gone over in checkout() */
#endif /* CHECKTIME */
/*
* init_check:
* Check out too see if it is proper to play the game now
*/
void
init_check()
{
#if defined(MAXLOAD) || defined(MAXUSERS)
if (too_much())
{
printf("Sorry, %s, but the system is too loaded now.\n", whoami);
printf("Try again later. Meanwhile, why not enjoy a%s %s?\n",
vowelstr(fruit), fruit);
if (author())
printf("However, since you're a good guy, it's up to you\n");
else
exit(1);
}
#endif
}
/*
* open_score:
* Open up the score file for future use
*/
void
open_score()
{
#ifdef SCOREFILE
char *scorefile = SCOREFILE;
/*
* We drop setgid privileges after opening the score file, so subsequent
* open()'s will fail. Just reuse the earlier filehandle.
*/
if (scoreboard != NULL) {
rewind(scoreboard);
return;
}
scoreboard = fopen(scorefile, "r+");
if ((scoreboard == NULL) && (errno == ENOENT))
{
scoreboard = fopen(scorefile, "w+");
md_chmod(scorefile,0664);
}
if (scoreboard == NULL) {
fprintf(stderr, "Could not open %s for writing: %s\n", scorefile, strerror(errno));
fflush(stderr);
}
#else
scoreboard = NULL;
#endif
}
/*
* setup:
* Get starting setup for all games
*/
void
setup()
{
#ifdef CHECKTIME
int checkout();
#endif
#ifdef DUMP
md_onsignal_autosave();
#else
md_onsignal_default();
#endif
#ifdef CHECKTIME
md_start_checkout_timer(CHECKTIME*60);
num_checks = 0;
#endif
raw(); /* Raw mode */
noecho(); /* Echo off */
keypad(stdscr,1);
getltchars(); /* get the local tty chars */
}
/*
* getltchars:
* Get the local tty chars for later use
*/
void
getltchars()
{
got_ltc = TRUE;
orig_dsusp = md_dsuspchar();
md_setdsuspchar( md_suspchar() );
}
/*
* resetltchars:
* Reset the local tty chars to original values.
*/
void
resetltchars(void)
{
if (got_ltc) {
md_setdsuspchar(orig_dsusp);
}
}
/*
* playltchars:
* Set local tty chars to the values we use when playing.
*/
void
playltchars(void)
{
if (got_ltc) {
md_setdsuspchar( md_suspchar() );
}
}
/*
* start_score:
* Start the scoring sequence
*/
void
start_score()
{
#ifdef CHECKTIME
md_stop_checkout_timer();
#endif
}
/*
* is_symlink:
* See if the file has a symbolic link
*/
bool
is_symlink(char *sp)
{
#ifdef S_IFLNK
struct stat sbuf2;
if (lstat(sp, &sbuf2) < 0)
return FALSE;
else
return ((sbuf2.st_mode & S_IFMT) != S_IFREG);
#else
NOOP(sp);
return FALSE;
#endif
}
#if defined(MAXLOAD) || defined(MAXUSERS)
/*
* too_much:
* See if the system is being used too much for this game
*/
bool
too_much()
{
#ifdef MAXLOAD
double avec[3];
#else
int cnt;
#endif
#ifdef MAXLOAD
md_loadav(avec);
if (avec[1] > (MAXLOAD / 10.0))
return TRUE;
#endif
#ifdef MAXUSERS
if (ucount() > MAXUSERS)
return TRUE;
#endif
return FALSE;
}
/*
* author:
* See if a user is an author of the program
*/
bool
author()
{
#ifdef MASTER
if (wizard)
return TRUE;
#endif
switch (md_getuid())
{
case -1:
return TRUE;
default:
return FALSE;
}
}
#endif
#ifdef CHECKTIME
/*
* checkout:
* Check each CHECKTIME seconds to see if the load is too high
*/
checkout(int sig)
{
static char *msgs[] = {
"The load is too high to be playing. Please leave in %0.1f minutes",
"Please save your game. You have %0.1f minutes",
"Last warning. You have %0.1f minutes to leave",
};
int checktime;
if (too_much())
{
if (author())
{
num_checks = 1;
chmsg("The load is rather high, O exaulted one");
}
else if (num_checks++ == 3)
fatal("Sorry. You took too long. You are dead\n");
checktime = (CHECKTIME * 60) / num_checks;
chmsg(msgs[num_checks - 1], ((double) checktime / 60.0));
}
else
{
if (num_checks)
{
num_checks = 0;
chmsg("The load has dropped back down. You have a reprieve");
}
checktime = (CHECKTIME * 60);
}
md_start_checkout_timer(checktime);
}
/*
* chmsg:
* checkout()'s version of msg. If we are in the middle of a
* shell, do a printf instead of a msg to a the refresh.
*/
/* VARARGS1 */
chmsg(char *fmt, int arg)
{
if (!in_shell)
msg(fmt, arg);
else
{
printf(fmt, arg);
putchar('\n');
fflush(stdout);
}
}
#endif
#ifdef UCOUNT
/*
* ucount:
* count number of users on the system
*/
#include <utmp.h>
struct utmp buf;
int
ucount()
{
struct utmp *up;
FILE *utmp;
int count;
if ((utmp = fopen(UTMP, "r")) == NULL)
return 0;
up = &buf;
count = 0;
while (fread(up, 1, sizeof (*up), utmp) > 0)
if (buf.ut_name[0] != '\0')
count++;
fclose(utmp);
return count;
}
#endif
/*
* lock_sc:
* lock the score file. If it takes too long, ask the user if
* they care to wait. Return TRUE if the lock is successful.
*/
static FILE *lfd = NULL;
bool
lock_sc()
{
#if defined(SCOREFILE) && defined(LOCKFILE)
int cnt;
static struct stat sbuf;
char *lockfile = LOCKFILE;
over:
if ((lfd=fopen(lockfile, "w+")) != NULL)
return TRUE;
for (cnt = 0; cnt < 5; cnt++)
{
md_sleep(1);
if ((lfd=fopen(lockfile, "w+")) != NULL)
return TRUE;
}
if (stat(lockfile, &sbuf) < 0)
{
lfd=fopen(lockfile, "w+");
return TRUE;
}
if (time(NULL) - sbuf.st_mtime > 10)
{
if (md_unlink(lockfile) < 0)
return FALSE;
goto over;
}
else
{
printf("The score file is very busy. Do you want to wait longer\n");
printf("for it to become free so your score can get posted?\n");
printf("If so, type \"y\"\n");
(void) fgets(prbuf, MAXSTR, stdin);
if (prbuf[0] == 'y')
for (;;)
{
if ((lfd=fopen(lockfile, "w+")) != 0)
return TRUE;
if (stat(lockfile, &sbuf) < 0)
{
lfd=fopen(lockfile, "w+");
return TRUE;
}
if (time(NULL) - sbuf.st_mtime > 10)
{
if (md_unlink(lockfile) < 0)
return FALSE;
}
md_sleep(1);
}
else
return FALSE;
}
#else
return TRUE;
#endif
}
/*
* unlock_sc:
* Unlock the score file
*/
void
unlock_sc()
{
#if defined(SCOREFILE) && defined(LOCKFILE)
if (lfd != NULL)
fclose(lfd);
lfd = NULL;
md_unlink(LOCKFILE);
#endif
}
/*
* flush_type:
* Flush typeahead for traps, etc.
*/
void
flush_type()
{
flushinp();
}

396
main.c
View File

@@ -1,396 +0,0 @@
/*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*
* @(#)main.c 4.22 (Berkeley) 02/05/99
*/
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <time.h>
#include <curses.h>
#include "rogue.h"
/*
* main:
* The main program, of course
*/
int
main(int argc, char **argv, char **envp)
{
char *env;
int lowtime;
md_init();
#ifdef MASTER
/*
* Check to see if he is a wizard
*/
if (argc >= 2 && argv[1][0] == '\0')
if (strcmp(PASSWD, md_crypt(md_getpass("wizard's password: "), "mT")) == 0)
{
wizard = TRUE;
player.t_flags |= SEEMONST;
argv++;
argc--;
}
#endif
/*
* get home and options from environment
*/
strncpy(home, md_gethomedir(), MAXSTR);
strcpy(file_name, home);
strcat(file_name, "rogue.save");
if ((env = getenv("ROGUEOPTS")) != NULL)
parse_opts(env);
if (env == NULL || whoami[0] == '\0')
strucpy(whoami, md_getusername(), (int) strlen(md_getusername()));
lowtime = (int) time(NULL);
#ifdef MASTER
if (wizard && getenv("SEED") != NULL)
dnum = atoi(getenv("SEED"));
else
#endif
dnum = lowtime + md_getpid();
seed = dnum;
open_score();
/*
* Drop setuid/setgid after opening the scoreboard file.
*/
md_normaluser();
/*
* check for print-score option
*/
md_normaluser(); /* we drop any setgid/setuid priveldges here */
if (argc == 2)
{
if (strcmp(argv[1], "-s") == 0)
{
noscore = TRUE;
score(0, -1, 0);
exit(0);
}
else if (strcmp(argv[1], "-d") == 0)
{
dnum = rnd(100); /* throw away some rnd()s to break patterns */
while (--dnum)
rnd(100);
purse = rnd(100) + 1;
level = rnd(100) + 1;
initscr();
getltchars();
death(death_monst());
exit(0);
}
}
init_check(); /* check for legal startup */
if (argc == 2)
if (!restore(argv[1], envp)) /* Note: restore will never return */
my_exit(1);
#ifdef MASTER
if (wizard)
printf("Hello %s, welcome to dungeon #%d", whoami, dnum);
else
#endif
printf("Hello %s, just a moment while I dig the dungeon...", whoami);
fflush(stdout);
initscr(); /* Start up cursor package */
init_probs(); /* Set up prob tables for objects */
init_player(); /* Set up initial player stats */
init_names(); /* Set up names of scrolls */
init_colors(); /* Set up colors of potions */
init_stones(); /* Set up stone settings of rings */
init_materials(); /* Set up materials of wands */
setup();
/*
* The screen must be at least NUMLINES x NUMCOLS
*/
if (LINES < NUMLINES || COLS < NUMCOLS)
{
printf("\nSorry, the screen must be at least %dx%d\n", NUMLINES, NUMCOLS);
endwin();
my_exit(1);
}
/*
* Set up windows
*/
hw = newwin(LINES, COLS, 0, 0);
idlok(stdscr, TRUE);
idlok(hw, TRUE);
#ifdef MASTER
noscore = wizard;
#endif
new_level(); /* Draw current level */
/*
* Start up daemons and fuses
*/
start_daemon(runners, 0, AFTER);
start_daemon(doctor, 0, AFTER);
fuse(swander, 0, WANDERTIME, AFTER);
start_daemon(stomach, 0, AFTER);
playit();
return(0);
}
/*
* endit:
* Exit the program abnormally.
*/
void
endit(int sig)
{
NOOP(sig);
fatal("Okay, bye bye!\n");
}
/*
* fatal:
* Exit the program, printing a message.
*/
void
fatal(char *s)
{
mvaddstr(LINES - 2, 0, s);
refresh();
endwin();
my_exit(0);
}
/*
* rnd:
* Pick a very random number.
*/
int
rnd(int range)
{
return range == 0 ? 0 : abs((int) RN) % range;
}
/*
* roll:
* Roll a number of dice
*/
int
roll(int number, int sides)
{
int dtotal = 0;
while (number--)
dtotal += rnd(sides)+1;
return dtotal;
}
/*
* tstp:
* Handle stop and start signals
*/
void
tstp(int ignored)
{
int y, x;
int oy, ox;
NOOP(ignored);
/*
* leave nicely
*/
getyx(curscr, oy, ox);
mvcur(0, COLS - 1, LINES - 1, 0);
endwin();
resetltchars();
fflush(stdout);
md_tstpsignal();
/*
* start back up again
*/
md_tstpresume();
raw();
noecho();
keypad(stdscr,1);
playltchars();
clearok(curscr, TRUE);
wrefresh(curscr);
getyx(curscr, y, x);
mvcur(y, x, oy, ox);
fflush(stdout);
curscr->_cury = oy;
curscr->_curx = ox;
}
/*
* playit:
* The main loop of the program. Loop until the game is over,
* refreshing things and looking at the proper times.
*/
void
playit()
{
char *opts;
/*
* set up defaults for slow terminals
*/
if (baudrate() <= 1200)
{
terse = TRUE;
jump = TRUE;
see_floor = FALSE;
}
if (md_hasclreol())
inv_type = INV_CLEAR;
/*
* parse environment declaration of options
*/
if ((opts = getenv("ROGUEOPTS")) != NULL)
parse_opts(opts);
oldpos = hero;
oldrp = roomin(&hero);
while (playing)
command(); /* Command execution */
endit(0);
}
/*
* quit:
* Have player make certain, then exit.
*/
void
quit(int sig)
{
int oy, ox;
NOOP(sig);
/*
* Reset the signal in case we got here via an interrupt
*/
if (!q_comm)
mpos = 0;
getyx(curscr, oy, ox);
msg("really quit?");
if (readchar() == 'y')
{
signal(SIGINT, leave);
clear();
mvprintw(LINES - 2, 0, "You quit with %d gold pieces", purse);
move(LINES - 1, 0);
refresh();
score(purse, 1, 0);
my_exit(0);
}
else
{
move(0, 0);
clrtoeol();
status();
move(oy, ox);
refresh();
mpos = 0;
count = 0;
to_death = FALSE;
}
}
/*
* leave:
* Leave quickly, but curteously
*/
void
leave(int sig)
{
static char buf[BUFSIZ];
NOOP(sig);
setbuf(stdout, buf); /* throw away pending output */
if (!isendwin())
{
mvcur(0, COLS - 1, LINES - 1, 0);
endwin();
}
putchar('\n');
my_exit(0);
}
/*
* shell:
* Let them escape for a while
*/
void
shell()
{
/*
* Set the terminal back to original mode
*/
move(LINES-1, 0);
refresh();
endwin();
resetltchars();
putchar('\n');
in_shell = TRUE;
after = FALSE;
fflush(stdout);
/*
* Fork and do a shell
*/
md_shellescape();
printf("\n[Press return to continue]");
fflush(stdout);
noecho();
raw();
keypad(stdscr,1);
playltchars();
in_shell = FALSE;
wait_for('\n');
clearok(stdscr, TRUE);
}
/*
* my_exit:
* Leave the process properly
*/
void
my_exit(int st)
{
resetltchars();
exit(st);
}

1432
mdport.c

File diff suppressed because it is too large Load Diff

597
misc.c
View File

@@ -1,597 +0,0 @@
/*
* All sorts of miscellaneous routines
*
* @(#)misc.c 4.66 (Berkeley) 08/06/83
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <stdlib.h>
#include <curses.h>
#include <string.h>
#include <ctype.h>
#include "rogue.h"
/*
* look:
* A quick glance all around the player
*/
#undef DEBUG
void
look(bool wakeup)
{
int x, y;
int ch;
THING *tp;
PLACE *pp;
struct room *rp;
int ey, ex;
int passcount;
char pfl, *fp, pch;
int sy, sx, sumhero = 0, diffhero = 0;
# ifdef DEBUG
static bool done = FALSE;
if (done)
return;
done = TRUE;
# endif /* DEBUG */
passcount = 0;
rp = proom;
if (!ce(oldpos, hero))
{
erase_lamp(&oldpos, oldrp);
oldpos = hero;
oldrp = rp;
}
ey = hero.y + 1;
ex = hero.x + 1;
sx = hero.x - 1;
sy = hero.y - 1;
if (door_stop && !firstmove && running)
{
sumhero = hero.y + hero.x;
diffhero = hero.y - hero.x;
}
pp = INDEX(hero.y, hero.x);
pch = pp->p_ch;
pfl = pp->p_flags;
for (y = sy; y <= ey; y++)
if (y > 0 && y < NUMLINES - 1) for (x = sx; x <= ex; x++)
{
if (x < 0 || x >= NUMCOLS)
continue;
if (!on(player, ISBLIND))
{
if (y == hero.y && x == hero.x)
continue;
}
pp = INDEX(y, x);
ch = pp->p_ch;
if (ch == ' ') /* nothing need be done with a ' ' */
continue;
fp = &pp->p_flags;
if (pch != DOOR && ch != DOOR)
if ((pfl & F_PASS) != (*fp & F_PASS))
continue;
if (((*fp & F_PASS) || ch == DOOR) &&
((pfl & F_PASS) || pch == DOOR))
{
if (hero.x != x && hero.y != y &&
!step_ok(chat(y, hero.x)) && !step_ok(chat(hero.y, x)))
continue;
}
if ((tp = pp->p_monst) == NULL)
ch = trip_ch(y, x, ch);
else
if (on(player, SEEMONST) && on(*tp, ISINVIS))
{
if (door_stop && !firstmove)
running = FALSE;
continue;
}
else
{
if (wakeup)
wake_monster(y, x);
if (see_monst(tp))
{
if (on(player, ISHALU))
ch = rnd(26) + 'A';
else
ch = tp->t_disguise;
}
}
if (on(player, ISBLIND) && (y != hero.y || x != hero.x))
continue;
move(y, x);
if ((proom->r_flags & ISDARK) && !see_floor && ch == FLOOR)
ch = ' ';
if (tp != NULL || ch != CCHAR( inch() ))
addch(ch);
if (door_stop && !firstmove && running)
{
switch (runch)
{
case 'h':
if (x == ex)
continue;
when 'j':
if (y == sy)
continue;
when 'k':
if (y == ey)
continue;
when 'l':
if (x == sx)
continue;
when 'y':
if ((y + x) - sumhero >= 1)
continue;
when 'u':
if ((y - x) - diffhero >= 1)
continue;
when 'n':
if ((y + x) - sumhero <= -1)
continue;
when 'b':
if ((y - x) - diffhero <= -1)
continue;
}
switch (ch)
{
case DOOR:
if (x == hero.x || y == hero.y)
running = FALSE;
break;
case PASSAGE:
if (x == hero.x || y == hero.y)
passcount++;
break;
case FLOOR:
case '|':
case '-':
case ' ':
break;
default:
running = FALSE;
break;
}
}
}
if (door_stop && !firstmove && passcount > 1)
running = FALSE;
if (!running || !jump)
mvaddch(hero.y, hero.x, PLAYER);
# ifdef DEBUG
done = FALSE;
# endif /* DEBUG */
}
/*
* trip_ch:
* Return the character appropriate for this space, taking into
* account whether or not the player is tripping.
*/
int
trip_ch(int y, int x, int ch)
{
if (on(player, ISHALU) && after)
switch (ch)
{
case FLOOR:
case ' ':
case PASSAGE:
case '-':
case '|':
case DOOR:
case TRAP:
break;
default:
if (y != stairs.y || x != stairs.x || !seenstairs)
ch = rnd_thing();
break;
}
return ch;
}
/*
* erase_lamp:
* Erase the area shown by a lamp in a dark room.
*/
void
erase_lamp(coord *pos, struct room *rp)
{
int y, x, ey, sy, ex;
if (!(see_floor && (rp->r_flags & (ISGONE|ISDARK)) == ISDARK
&& !on(player,ISBLIND)))
return;
ey = pos->y + 1;
ex = pos->x + 1;
sy = pos->y - 1;
for (x = pos->x - 1; x <= ex; x++)
for (y = sy; y <= ey; y++)
{
if (y == hero.y && x == hero.x)
continue;
move(y, x);
if (inch() == FLOOR)
addch(' ');
}
}
/*
* show_floor:
* Should we show the floor in her room at this time?
*/
bool
show_floor()
{
if ((proom->r_flags & (ISGONE|ISDARK)) == ISDARK && !on(player, ISBLIND))
return see_floor;
else
return TRUE;
}
/*
* find_obj:
* Find the unclaimed object at y, x
*/
THING *
find_obj(int y, int x)
{
THING *obj;
for (obj = lvl_obj; obj != NULL; obj = next(obj))
{
if (obj->o_pos.y == y && obj->o_pos.x == x)
return obj;
}
#ifdef MASTER
sprintf(prbuf, "Non-object %d,%d", y, x);
msg(prbuf);
return NULL;
#else
/* NOTREACHED */
return NULL;
#endif
}
/*
* eat:
* She wants to eat something, so let her try
*/
void
eat()
{
THING *obj;
if ((obj = get_item("eat", FOOD)) == NULL)
return;
if (obj->o_type != FOOD)
{
if (!terse)
msg("ugh, you would get ill if you ate that");
else
msg("that's Inedible!");
return;
}
if (food_left < 0)
food_left = 0;
if ((food_left += HUNGERTIME - 200 + rnd(400)) > STOMACHSIZE)
food_left = STOMACHSIZE;
hungry_state = 0;
if (obj == cur_weapon)
cur_weapon = NULL;
if (obj->o_which == 1)
msg("my, that was a yummy %s", fruit);
else
if (rnd(100) > 70)
{
pstats.s_exp++;
msg("%s, this food tastes awful", choose_str("bummer", "yuk"));
check_level();
}
else
msg("%s, that tasted good", choose_str("oh, wow", "yum"));
leave_pack(obj, FALSE, FALSE);
}
/*
* check_level:
* Check to see if the guy has gone up a level.
*/
void
check_level()
{
int i, add, olevel;
for (i = 0; e_levels[i] != 0; i++)
if (e_levels[i] > pstats.s_exp)
break;
i++;
olevel = pstats.s_lvl;
pstats.s_lvl = i;
if (i > olevel)
{
add = roll(i - olevel, 10);
max_hp += add;
pstats.s_hpt += add;
msg("welcome to level %d", i);
}
}
/*
* chg_str:
* used to modify the playes strength. It keeps track of the
* highest it has been, just in case
*/
void
chg_str(int amt)
{
auto str_t comp;
if (amt == 0)
return;
add_str(&pstats.s_str, amt);
comp = pstats.s_str;
if (ISRING(LEFT, R_ADDSTR))
add_str(&comp, -cur_ring[LEFT]->o_arm);
if (ISRING(RIGHT, R_ADDSTR))
add_str(&comp, -cur_ring[RIGHT]->o_arm);
if (comp > max_stats.s_str)
max_stats.s_str = comp;
}
/*
* add_str:
* Perform the actual add, checking upper and lower bound limits
*/
void
add_str(str_t *sp, int amt)
{
if ((*sp += amt) < 3)
*sp = 3;
else if (*sp > 31)
*sp = 31;
}
/*
* add_haste:
* Add a haste to the player
*/
bool
add_haste(bool potion)
{
if (on(player, ISHASTE))
{
no_command += rnd(8);
player.t_flags &= ~(ISRUN|ISHASTE);
extinguish(nohaste);
msg("you faint from exhaustion");
return FALSE;
}
else
{
player.t_flags |= ISHASTE;
if (potion)
fuse(nohaste, 0, rnd(4)+4, AFTER);
return TRUE;
}
}
/*
* aggravate:
* Aggravate all the monsters on this level
*/
void
aggravate()
{
THING *mp;
for (mp = mlist; mp != NULL; mp = next(mp))
runto(&mp->t_pos);
}
/*
* vowelstr:
* For printfs: if string starts with a vowel, return "n" for an
* "an".
*/
char *
vowelstr(char *str)
{
switch (*str)
{
case 'a': case 'A':
case 'e': case 'E':
case 'i': case 'I':
case 'o': case 'O':
case 'u': case 'U':
return "n";
default:
return "";
}
}
/*
* is_current:
* See if the object is one of the currently used items
*/
bool
is_current(THING *obj)
{
if (obj == NULL)
return FALSE;
if (obj == cur_armor || obj == cur_weapon || obj == cur_ring[LEFT]
|| obj == cur_ring[RIGHT])
{
if (!terse)
addmsg("That's already ");
msg("in use");
return TRUE;
}
return FALSE;
}
/*
* get_dir:
* Set up the direction co_ordinate for use in varios "prefix"
* commands
*/
bool
get_dir()
{
char *prompt;
bool gotit;
static coord last_delt= {0,0};
if (again && last_dir != '\0')
{
delta.y = last_delt.y;
delta.x = last_delt.x;
dir_ch = last_dir;
}
else
{
if (!terse)
msg(prompt = "which direction? ");
else
prompt = "direction: ";
do
{
gotit = TRUE;
switch (dir_ch = readchar())
{
case 'h': case'H': delta.y = 0; delta.x = -1;
when 'j': case'J': delta.y = 1; delta.x = 0;
when 'k': case'K': delta.y = -1; delta.x = 0;
when 'l': case'L': delta.y = 0; delta.x = 1;
when 'y': case'Y': delta.y = -1; delta.x = -1;
when 'u': case'U': delta.y = -1; delta.x = 1;
when 'b': case'B': delta.y = 1; delta.x = -1;
when 'n': case'N': delta.y = 1; delta.x = 1;
when ESCAPE: last_dir = '\0'; reset_last(); return FALSE;
otherwise:
mpos = 0;
msg(prompt);
gotit = FALSE;
}
} until (gotit);
if (isupper(dir_ch))
dir_ch = (char) tolower(dir_ch);
last_dir = dir_ch;
last_delt.y = delta.y;
last_delt.x = delta.x;
}
if (on(player, ISHUH) && rnd(5) == 0)
do
{
delta.y = rnd(3) - 1;
delta.x = rnd(3) - 1;
} while (delta.y == 0 && delta.x == 0);
mpos = 0;
return TRUE;
}
/*
* sign:
* Return the sign of the number
*/
int
sign(int nm)
{
if (nm < 0)
return -1;
else
return (nm > 0);
}
/*
* spread:
* Give a spread around a given number (+/- 20%)
*/
int
spread(int nm)
{
return nm - nm / 20 + rnd(nm / 10);
}
/*
* call_it:
* Call an object something after use.
*/
void
call_it(struct obj_info *info)
{
if (info->oi_know)
{
if (info->oi_guess)
{
free(info->oi_guess);
info->oi_guess = NULL;
}
}
else if (!info->oi_guess)
{
msg(terse ? "call it: " : "what do you want to call it? ");
if (get_str(prbuf, stdscr) == NORM)
{
if (info->oi_guess != NULL)
free(info->oi_guess);
info->oi_guess = malloc((unsigned int) strlen(prbuf) + 1);
strcpy(info->oi_guess, prbuf);
}
}
}
/*
* rnd_thing:
* Pick a random thing appropriate for this level
*/
char
rnd_thing()
{
int i;
static char thing_list[] = {
POTION, SCROLL, RING, STICK, FOOD, WEAPON, ARMOR, STAIRS, GOLD, AMULET
};
if (level >= AMULETLEVEL)
i = rnd(sizeof thing_list / sizeof (char));
else
i = rnd(sizeof thing_list / sizeof (char) - 1);
return thing_list[i];
}
/*
str str:
* Choose the first or second string depending on whether it the
* player is tripping
*/
char *
choose_str(char *ts, char *ns)
{
return (on(player, ISHALU) ? ts : ns);
}

View File

@@ -1,252 +0,0 @@
/*
* File with various monster functions in it
*
* @(#)monsters.c 4.46 (Berkeley) 02/05/99
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <curses.h>
#include <string.h>
#include "rogue.h"
#include <ctype.h>
/*
* List of monsters in rough order of vorpalness
*/
static char lvl_mons[] = {
'K', 'E', 'B', 'S', 'H', 'I', 'R', 'O', 'Z', 'L', 'C', 'Q', 'A',
'N', 'Y', 'F', 'T', 'W', 'P', 'X', 'U', 'M', 'V', 'G', 'J', 'D'
};
static char wand_mons[] = {
'K', 'E', 'B', 'S', 'H', 0, 'R', 'O', 'Z', 0, 'C', 'Q', 'A',
0, 'Y', 0, 'T', 'W', 'P', 0, 'U', 'M', 'V', 'G', 'J', 0
};
/*
* randmonster:
* Pick a monster to show up. The lower the level,
* the meaner the monster.
*/
char
randmonster(bool wander)
{
int d;
char *mons;
mons = (wander ? wand_mons : lvl_mons);
do
{
d = level + (rnd(10) - 6);
if (d < 0)
d = rnd(5);
if (d > 25)
d = rnd(5) + 21;
} while (mons[d] == 0);
return mons[d];
}
/*
* new_monster:
* Pick a new monster and add it to the list
*/
void
new_monster(THING *tp, char type, coord *cp)
{
struct monster *mp;
int lev_add;
if ((lev_add = level - AMULETLEVEL) < 0)
lev_add = 0;
attach(mlist, tp);
tp->t_type = type;
tp->t_disguise = type;
tp->t_pos = *cp;
move(cp->y, cp->x);
tp->t_oldch = CCHAR( inch() );
tp->t_room = roomin(cp);
moat(cp->y, cp->x) = tp;
mp = &monsters[tp->t_type-'A'];
tp->t_stats.s_lvl = mp->m_stats.s_lvl + lev_add;
tp->t_stats.s_maxhp = tp->t_stats.s_hpt = roll(tp->t_stats.s_lvl, 8);
tp->t_stats.s_arm = mp->m_stats.s_arm - lev_add;
strcpy(tp->t_stats.s_dmg,mp->m_stats.s_dmg);
tp->t_stats.s_str = mp->m_stats.s_str;
tp->t_stats.s_exp = mp->m_stats.s_exp + lev_add * 10 + exp_add(tp);
tp->t_flags = mp->m_flags;
if (level > 29)
tp->t_flags |= ISHASTE;
tp->t_turn = TRUE;
tp->t_pack = NULL;
if (ISWEARING(R_AGGR))
runto(cp);
if (type == 'X')
tp->t_disguise = rnd_thing();
}
/*
* expadd:
* Experience to add for this monster's level/hit points
*/
int
exp_add(THING *tp)
{
int mod;
if (tp->t_stats.s_lvl == 1)
mod = tp->t_stats.s_maxhp / 8;
else
mod = tp->t_stats.s_maxhp / 6;
if (tp->t_stats.s_lvl > 9)
mod *= 20;
else if (tp->t_stats.s_lvl > 6)
mod *= 4;
return mod;
}
/*
* wanderer:
* Create a new wandering monster and aim it at the player
*/
void
wanderer()
{
THING *tp;
static coord cp;
tp = new_item();
do
{
find_floor((struct room *) NULL, &cp, FALSE, TRUE);
} while (roomin(&cp) == proom);
new_monster(tp, randmonster(TRUE), &cp);
if (on(player, SEEMONST))
{
standout();
if (!on(player, ISHALU))
addch(tp->t_type);
else
addch(rnd(26) + 'A');
standend();
}
runto(&tp->t_pos);
#ifdef MASTER
if (wizard)
msg("started a wandering %s", monsters[tp->t_type-'A'].m_name);
#endif
}
/*
* wake_monster:
* What to do when the hero steps next to a monster
*/
THING *
wake_monster(int y, int x)
{
THING *tp;
struct room *rp;
char ch, *mname;
#ifdef MASTER
if ((tp = moat(y, x)) == NULL)
msg("can't find monster in wake_monster");
#else
tp = moat(y, x);
if (tp == NULL)
endwin(), abort();
#endif
ch = tp->t_type;
/*
* Every time he sees mean monster, it might start chasing him
*/
if (!on(*tp, ISRUN) && rnd(3) != 0 && on(*tp, ISMEAN) && !on(*tp, ISHELD)
&& !ISWEARING(R_STEALTH) && !on(player, ISLEVIT))
{
tp->t_dest = &hero;
tp->t_flags |= ISRUN;
}
if (ch == 'M' && !on(player, ISBLIND) && !on(player, ISHALU)
&& !on(*tp, ISFOUND) && !on(*tp, ISCANC) && on(*tp, ISRUN))
{
rp = proom;
if ((rp != NULL && !(rp->r_flags & ISDARK))
|| dist(y, x, hero.y, hero.x) < LAMPDIST)
{
tp->t_flags |= ISFOUND;
if (!save(VS_MAGIC))
{
if (on(player, ISHUH))
lengthen(unconfuse, spread(HUHDURATION));
else
fuse(unconfuse, 0, spread(HUHDURATION), AFTER);
player.t_flags |= ISHUH;
mname = set_mname(tp);
addmsg("%s", mname);
if (strcmp(mname, "it") != 0)
addmsg("'");
msg("s gaze has confused you");
}
}
}
/*
* Let greedy ones guard gold
*/
if (on(*tp, ISGREED) && !on(*tp, ISRUN))
{
tp->t_flags |= ISRUN;
if (proom->r_goldval)
tp->t_dest = &proom->r_gold;
else
tp->t_dest = &hero;
}
return tp;
}
/*
* give_pack:
* Give a pack to a monster if it deserves one
*/
void
give_pack(THING *tp)
{
if (level >= max_level && rnd(100) < monsters[tp->t_type-'A'].m_carry)
attach(tp->t_pack, new_thing());
}
/*
* save_throw:
* See if a creature save against something
*/
int
save_throw(int which, THING *tp)
{
int need;
need = 14 + which - tp->t_stats.s_lvl / 2;
return (roll(1, 20) >= need);
}
/*
* save:
* See if he saves against various nasty things
*/
int
save(int which)
{
if (which == VS_MAGIC)
{
if (ISRING(LEFT, R_PROTECT))
which -= cur_ring[LEFT]->o_arm;
if (ISRING(RIGHT, R_PROTECT))
which -= cur_ring[RIGHT]->o_arm;
}
return save_throw(which, &player);
}

425
move.c
View File

@@ -1,425 +0,0 @@
/*
* hero movement commands
*
* @(#)move.c 4.49 (Berkeley) 02/05/99
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <curses.h>
#include <ctype.h>
#include "rogue.h"
/*
* used to hold the new hero position
*/
coord nh;
/*
* do_run:
* Start the hero running
*/
void
do_run(char ch)
{
running = TRUE;
after = FALSE;
runch = ch;
}
/*
* do_move:
* Check to see that a move is legal. If it is handle the
* consequences (fighting, picking up, etc.)
*/
void
do_move(int dy, int dx)
{
char ch, fl;
firstmove = FALSE;
if (no_move)
{
no_move--;
msg("you are still stuck in the bear trap");
return;
}
/*
* Do a confused move (maybe)
*/
if (on(player, ISHUH) && rnd(5) != 0)
{
nh = *rndmove(&player);
if (ce(nh, hero))
{
after = FALSE;
running = FALSE;
to_death = FALSE;
return;
}
}
else
{
over:
nh.y = hero.y + dy;
nh.x = hero.x + dx;
}
/*
* Check if he tried to move off the screen or make an illegal
* diagonal move, and stop him if he did.
*/
if (nh.x < 0 || nh.x >= NUMCOLS || nh.y <= 0 || nh.y >= NUMLINES - 1)
goto hit_bound;
if (!diag_ok(&hero, &nh))
{
after = FALSE;
running = FALSE;
return;
}
if (running && ce(hero, nh))
after = running = FALSE;
fl = flat(nh.y, nh.x);
ch = winat(nh.y, nh.x);
if (!(fl & F_REAL) && ch == FLOOR)
{
if (!on(player, ISLEVIT))
{
chat(nh.y, nh.x) = ch = TRAP;
flat(nh.y, nh.x) |= F_REAL;
}
}
else if (on(player, ISHELD) && ch != 'F')
{
msg("you are being held");
return;
}
switch (ch)
{
case ' ':
case '|':
case '-':
hit_bound:
if (passgo && running && (proom->r_flags & ISGONE)
&& !on(player, ISBLIND))
{
bool b1, b2;
switch (runch)
{
case 'h':
case 'l':
b1 = (bool)(hero.y != 1 && turn_ok(hero.y - 1, hero.x));
b2 = (bool)(hero.y != NUMLINES - 2 && turn_ok(hero.y + 1, hero.x));
if (!(b1 ^ b2))
break;
if (b1)
{
runch = 'k';
dy = -1;
}
else
{
runch = 'j';
dy = 1;
}
dx = 0;
turnref();
goto over;
case 'j':
case 'k':
b1 = (bool)(hero.x != 0 && turn_ok(hero.y, hero.x - 1));
b2 = (bool)(hero.x != NUMCOLS - 1 && turn_ok(hero.y, hero.x + 1));
if (!(b1 ^ b2))
break;
if (b1)
{
runch = 'h';
dx = -1;
}
else
{
runch = 'l';
dx = 1;
}
dy = 0;
turnref();
goto over;
}
}
running = FALSE;
after = FALSE;
break;
case DOOR:
running = FALSE;
if (flat(hero.y, hero.x) & F_PASS)
enter_room(&nh);
goto move_stuff;
case TRAP:
ch = be_trapped(&nh);
if (ch == T_DOOR || ch == T_TELEP)
return;
goto move_stuff;
case PASSAGE:
/*
* when you're in a corridor, you don't know if you're in
* a maze room or not, and there ain't no way to find out
* if you're leaving a maze room, so it is necessary to
* always recalculate proom.
*/
proom = roomin(&hero);
goto move_stuff;
case FLOOR:
if (!(fl & F_REAL))
be_trapped(&hero);
goto move_stuff;
case STAIRS:
seenstairs = TRUE;
/* FALLTHROUGH */
default:
running = FALSE;
if (isupper(ch) || moat(nh.y, nh.x))
fight(&nh, cur_weapon, FALSE);
else
{
if (ch != STAIRS)
take = ch;
move_stuff:
mvaddch(hero.y, hero.x, floor_at());
if ((fl & F_PASS) && chat(oldpos.y, oldpos.x) == DOOR)
leave_room(&nh);
hero = nh;
}
}
}
/*
* turn_ok:
* Decide whether it is legal to turn onto the given space
*/
bool
turn_ok(int y, int x)
{
PLACE *pp;
pp = INDEX(y, x);
return (pp->p_ch == DOOR
|| (pp->p_flags & (F_REAL|F_PASS)) == (F_REAL|F_PASS));
}
/*
* turnref:
* Decide whether to refresh at a passage turning or not
*/
void
turnref()
{
PLACE *pp;
pp = INDEX(hero.y, hero.x);
if (!(pp->p_flags & F_SEEN))
{
if (jump)
{
leaveok(stdscr, TRUE);
refresh();
leaveok(stdscr, FALSE);
}
pp->p_flags |= F_SEEN;
}
}
/*
* door_open:
* Called to illuminate a room. If it is dark, remove anything
* that might move.
*/
void
door_open(struct room *rp)
{
int y, x;
if (!(rp->r_flags & ISGONE))
for (y = rp->r_pos.y; y < rp->r_pos.y + rp->r_max.y; y++)
for (x = rp->r_pos.x; x < rp->r_pos.x + rp->r_max.x; x++)
if (isupper(winat(y, x)))
wake_monster(y, x);
}
/*
* be_trapped:
* The guy stepped on a trap.... Make him pay.
*/
char
be_trapped(coord *tc)
{
PLACE *pp;
THING *arrow;
char tr;
if (on(player, ISLEVIT))
return T_RUST; /* anything that's not a door or teleport */
running = FALSE;
count = FALSE;
pp = INDEX(tc->y, tc->x);
pp->p_ch = TRAP;
tr = pp->p_flags & F_TMASK;
pp->p_flags |= F_SEEN;
switch (tr)
{
case T_DOOR:
level++;
new_level();
msg("you fell into a trap!");
when T_BEAR:
no_move += BEARTIME;
msg("you are caught in a bear trap");
when T_MYST:
switch(rnd(11))
{
case 0: msg("you are suddenly in a parallel dimension");
when 1: msg("the light in here suddenly seems %s", rainbow[rnd(cNCOLORS)]);
when 2: msg("you feel a sting in the side of your neck");
when 3: msg("multi-colored lines swirl around you, then fade");
when 4: msg("a %s light flashes in your eyes", rainbow[rnd(cNCOLORS)]);
when 5: msg("a spike shoots past your ear!");
when 6: msg("%s sparks dance across your armor", rainbow[rnd(cNCOLORS)]);
when 7: msg("you suddenly feel very thirsty");
when 8: msg("you feel time speed up suddenly");
when 9: msg("time now seems to be going slower");
when 10: msg("you pack turns %s!", rainbow[rnd(cNCOLORS)]);
}
when T_SLEEP:
no_command += SLEEPTIME;
player.t_flags &= ~ISRUN;
msg("a strange white mist envelops you and you fall asleep");
when T_ARROW:
if (swing(pstats.s_lvl - 1, pstats.s_arm, 1))
{
pstats.s_hpt -= roll(1, 6);
if (pstats.s_hpt <= 0)
{
msg("an arrow killed you");
death('a');
}
else
msg("oh no! An arrow shot you");
}
else
{
arrow = new_item();
init_weapon(arrow, ARROW);
arrow->o_count = 1;
arrow->o_pos = hero;
fall(arrow, FALSE);
msg("an arrow shoots past you");
}
when T_TELEP:
/*
* since the hero's leaving, look() won't put a TRAP
* down for us, so we have to do it ourself
*/
teleport();
mvaddch(tc->y, tc->x, TRAP);
when T_DART:
if (!swing(pstats.s_lvl+1, pstats.s_arm, 1))
msg("a small dart whizzes by your ear and vanishes");
else
{
pstats.s_hpt -= roll(1, 4);
if (pstats.s_hpt <= 0)
{
msg("a poisoned dart killed you");
death('d');
}
if (!ISWEARING(R_SUSTSTR) && !save(VS_POISON))
chg_str(-1);
msg("a small dart just hit you in the shoulder");
}
when T_RUST:
msg("a gush of water hits you on the head");
rust_armor(cur_armor);
}
flush_type();
return tr;
}
/*
* rndmove:
* Move in a random direction if the monster/person is confused
*/
coord *
rndmove(THING *who)
{
THING *obj;
int x, y;
char ch;
static coord ret; /* what we will be returning */
y = ret.y = who->t_pos.y + rnd(3) - 1;
x = ret.x = who->t_pos.x + rnd(3) - 1;
/*
* Now check to see if that's a legal move. If not, don't move.
* (I.e., bump into the wall or whatever)
*/
if (y == who->t_pos.y && x == who->t_pos.x)
return &ret;
if (!diag_ok(&who->t_pos, &ret))
goto bad;
else
{
ch = winat(y, x);
if (!step_ok(ch))
goto bad;
if (ch == SCROLL)
{
for (obj = lvl_obj; obj != NULL; obj = next(obj))
if (y == obj->o_pos.y && x == obj->o_pos.x)
break;
if (obj != NULL && obj->o_which == S_SCARE)
goto bad;
}
}
return &ret;
bad:
ret = who->t_pos;
return &ret;
}
/*
* rust_armor:
* Rust the given armor, if it is a legal kind to rust, and we
* aren't wearing a magic ring.
*/
void
rust_armor(THING *arm)
{
if (arm == NULL || arm->o_type != ARMOR || arm->o_which == LEATHER ||
arm->o_arm >= 9)
return;
if ((arm->o_flags & ISPROT) || ISWEARING(R_SUSTARM))
{
if (!to_death)
msg("the rust vanishes instantly");
}
else
{
arm->o_arm++;
if (!terse)
msg("your armor appears to be weaker now. Oh my!");
else
msg("your armor weakens");
}
}

View File

@@ -1,231 +0,0 @@
/*
* new_level:
* Dig and draw a new level
*
* @(#)new_level.c 4.38 (Berkeley) 02/05/99
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <curses.h>
#include <string.h>
#include "rogue.h"
#define TREAS_ROOM 20 /* one chance in TREAS_ROOM for a treasure room */
#define MAXTREAS 10 /* maximum number of treasures in a treasure room */
#define MINTREAS 2 /* minimum number of treasures in a treasure room */
void
new_level()
{
THING *tp;
PLACE *pp;
char *sp;
int i;
player.t_flags &= ~ISHELD; /* unhold when you go down just in case */
if (level > max_level)
max_level = level;
/*
* Clean things off from last level
*/
for (pp = places; pp < &places[MAXCOLS*MAXLINES]; pp++)
{
pp->p_ch = ' ';
pp->p_flags = F_REAL;
pp->p_monst = NULL;
}
clear();
/*
* Free up the monsters on the last level
*/
for (tp = mlist; tp != NULL; tp = next(tp))
free_list(tp->t_pack);
free_list(mlist);
/*
* Throw away stuff left on the previous level (if anything)
*/
free_list(lvl_obj);
do_rooms(); /* Draw rooms */
do_passages(); /* Draw passages */
no_food++;
put_things(); /* Place objects (if any) */
/*
* Place the traps
*/
if (rnd(10) < level)
{
ntraps = rnd(level / 4) + 1;
if (ntraps > MAXTRAPS)
ntraps = MAXTRAPS;
i = ntraps;
while (i--)
{
/*
* not only wouldn't it be NICE to have traps in mazes
* (not that we care about being nice), since the trap
* number is stored where the passage number is, we
* can't actually do it.
*/
do
{
find_floor((struct room *) NULL, &stairs, FALSE, FALSE);
} while (chat(stairs.y, stairs.x) != FLOOR);
sp = &flat(stairs.y, stairs.x);
*sp &= ~F_REAL;
*sp |= rnd(NTRAPS);
}
}
/*
* Place the staircase down.
*/
find_floor((struct room *) NULL, &stairs, FALSE, FALSE);
chat(stairs.y, stairs.x) = STAIRS;
seenstairs = FALSE;
for (tp = mlist; tp != NULL; tp = next(tp))
tp->t_room = roomin(&tp->t_pos);
find_floor((struct room *) NULL, &hero, FALSE, TRUE);
enter_room(&hero);
mvaddch(hero.y, hero.x, PLAYER);
if (on(player, SEEMONST))
turn_see(FALSE);
if (on(player, ISHALU))
visuals();
}
/*
* rnd_room:
* Pick a room that is really there
*/
int
rnd_room()
{
int rm;
do
{
rm = rnd(MAXROOMS);
} while (rooms[rm].r_flags & ISGONE);
return rm;
}
/*
* put_things:
* Put potions and scrolls on this level
*/
void
put_things()
{
int i;
THING *obj;
/*
* Once you have found the amulet, the only way to get new stuff is
* go down into the dungeon.
*/
if (amulet && level < max_level)
return;
/*
* check for treasure rooms, and if so, put it in.
*/
if (rnd(TREAS_ROOM) == 0)
treas_room();
/*
* Do MAXOBJ attempts to put things on a level
*/
for (i = 0; i < MAXOBJ; i++)
if (rnd(100) < 36)
{
/*
* Pick a new object and link it in the list
*/
obj = new_thing();
attach(lvl_obj, obj);
/*
* Put it somewhere
*/
find_floor((struct room *) NULL, &obj->o_pos, FALSE, FALSE);
chat(obj->o_pos.y, obj->o_pos.x) = (char) obj->o_type;
}
/*
* If he is really deep in the dungeon and he hasn't found the
* amulet yet, put it somewhere on the ground
*/
if (level >= AMULETLEVEL && !amulet)
{
obj = new_item();
attach(lvl_obj, obj);
obj->o_hplus = 0;
obj->o_dplus = 0;
strncpy(obj->o_damage,"0x0",sizeof(obj->o_damage));
strncpy(obj->o_hurldmg,"0x0",sizeof(obj->o_hurldmg));
obj->o_arm = 11;
obj->o_type = AMULET;
/*
* Put it somewhere
*/
find_floor((struct room *) NULL, &obj->o_pos, FALSE, FALSE);
chat(obj->o_pos.y, obj->o_pos.x) = AMULET;
}
}
/*
* treas_room:
* Add a treasure room
*/
#define MAXTRIES 10 /* max number of tries to put down a monster */
void
treas_room()
{
int nm;
THING *tp;
struct room *rp;
int spots, num_monst;
static coord mp;
rp = &rooms[rnd_room()];
spots = (rp->r_max.y - 2) * (rp->r_max.x - 2) - MINTREAS;
if (spots > (MAXTREAS - MINTREAS))
spots = (MAXTREAS - MINTREAS);
num_monst = nm = rnd(spots) + MINTREAS;
while (nm--)
{
find_floor(rp, &mp, 2 * MAXTRIES, FALSE);
tp = new_thing();
tp->o_pos = mp;
attach(lvl_obj, tp);
chat(mp.y, mp.x) = (char) tp->o_type;
}
/*
* fill up room with monsters from the next level down
*/
if ((nm = rnd(spots) + MINTREAS) < num_monst + 2)
nm = num_monst + 2;
spots = (rp->r_max.y - 2) * (rp->r_max.x - 2);
if (nm > spots)
nm = spots;
level++;
while (nm--)
{
spots = 0;
if (find_floor(rp, &mp, MAXTRIES, TRUE))
{
tp = new_item();
new_monster(tp, randmonster(FALSE), &mp);
tp->t_flags |= ISMEAN; /* no sloughers in THIS room */
give_pack(tp);
}
}
level--;
}

501
options.c
View File

@@ -1,501 +0,0 @@
/*
* This file has all the code for the option command. I would rather
* this command were not necessary, but it is the only way to keep the
* wolves off of my back.
*
* @(#)options.c 4.24 (Berkeley) 05/10/83
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <stdlib.h>
#include <curses.h>
#include <ctype.h>
#include <string.h>
#include "rogue.h"
#define EQSTR(a, b, c) (strncmp(a, b, c) == 0)
#define NUM_OPTS (sizeof optlist / sizeof (OPTION))
/*
* description of an option and what to do with it
*/
struct optstruct {
char *o_name; /* option name */
char *o_prompt; /* prompt for interactive entry */
void *o_opt; /* pointer to thing to set */
/* function to print value */
void (*o_putfunc)(void *opt);
/* function to get value interactively */
int (*o_getfunc)(void *opt, WINDOW *win);
};
typedef struct optstruct OPTION;
void pr_optname(OPTION *op);
OPTION optlist[] = {
{"terse", "Terse output",
&terse, put_bool, get_bool },
{"flush", "Flush typeahead during battle",
&fight_flush, put_bool, get_bool },
{"jump", "Show position only at end of run",
&jump, put_bool, get_bool },
{"seefloor", "Show the lamp-illuminated floor",
&see_floor, put_bool, get_sf },
{"passgo", "Follow turnings in passageways",
&passgo, put_bool, get_bool },
{"tombstone", "Print out tombstone when killed",
&tombstone, put_bool, get_bool },
{"inven", "Inventory style",
&inv_type, put_inv_t, get_inv_t },
{"name", "Name",
whoami, put_str, get_str },
{"fruit", "Fruit",
fruit, put_str, get_str },
{"file", "Save file",
file_name, put_str, get_str }
};
/*
* option:
* Print and then set options from the terminal
*/
void
option()
{
OPTION *op;
int retval;
wclear(hw);
/*
* Display current values of options
*/
for (op = optlist; op <= &optlist[NUM_OPTS-1]; op++)
{
pr_optname(op);
(*op->o_putfunc)(op->o_opt);
waddch(hw, '\n');
}
/*
* Set values
*/
wmove(hw, 0, 0);
for (op = optlist; op <= &optlist[NUM_OPTS-1]; op++)
{
pr_optname(op);
retval = (*op->o_getfunc)(op->o_opt, hw);
if (retval)
{
if (retval == QUIT)
break;
else if (op > optlist) { /* MINUS */
wmove(hw, (int)(op - optlist) - 1, 0);
op -= 2;
}
else /* trying to back up beyond the top */
{
putchar('\007');
wmove(hw, 0, 0);
op--;
}
}
}
/*
* Switch back to original screen
*/
wmove(hw, LINES - 1, 0);
waddstr(hw, "--Press space to continue--");
wrefresh(hw);
wait_for(' ');
clearok(curscr, TRUE);
touchwin(stdscr);
after = FALSE;
}
/*
* pr_optname:
* Print out the option name prompt
*/
void
pr_optname(OPTION *op)
{
wprintw(hw, "%s (\"%s\"): ", op->o_prompt, op->o_name);
}
/*
* put_bool
* Put out a boolean
*/
void
put_bool(void *b)
{
waddstr(hw, *(bool *) b ? "True" : "False");
}
/*
* put_str:
* Put out a string
*/
void
put_str(void *str)
{
waddstr(hw, (char *) str);
}
/*
* put_inv_t:
* Put out an inventory type
*/
void
put_inv_t(void *ip)
{
waddstr(hw, inv_t_name[*(int *) ip]);
}
/*
* get_bool:
* Allow changing a boolean option and print it out
*/
int
get_bool(void *vp, WINDOW *win)
{
bool *bp = (bool *) vp;
int oy, ox;
bool op_bad;
op_bad = TRUE;
getyx(win, oy, ox);
waddstr(win, *bp ? "True" : "False");
while (op_bad)
{
wmove(win, oy, ox);
wrefresh(win);
switch (readchar())
{
case 't':
case 'T':
*bp = TRUE;
op_bad = FALSE;
break;
case 'f':
case 'F':
*bp = FALSE;
op_bad = FALSE;
break;
case '\n':
case '\r':
op_bad = FALSE;
break;
case ESCAPE:
return QUIT;
case '-':
return MINUS;
default:
wmove(win, oy, ox + 10);
waddstr(win, "(T or F)");
}
}
wmove(win, oy, ox);
waddstr(win, *bp ? "True" : "False");
waddch(win, '\n');
return NORM;
}
/*
* get_sf:
* Change value and handle transition problems from see_floor to
* !see_floor.
*/
int
get_sf(void *vp, WINDOW *win)
{
bool *bp = (bool *) vp;
bool was_sf;
int retval;
was_sf = see_floor;
retval = get_bool(bp, win);
if (retval == QUIT) return(QUIT);
if (was_sf != see_floor)
{
if (!see_floor) {
see_floor = TRUE;
erase_lamp(&hero, proom);
see_floor = FALSE;
}
else
look(FALSE);
}
return(NORM);
}
/*
* get_str:
* Set a string option
*/
#define MAXINP 50 /* max string to read from terminal or environment */
int
get_str(void *vopt, WINDOW *win)
{
char *opt = (char *) vopt;
char *sp;
int oy, ox;
int i;
signed char c;
static char buf[MAXSTR];
getyx(win, oy, ox);
wrefresh(win);
/*
* loop reading in the string, and put it in a temporary buffer
*/
for (sp = buf; (c = readchar()) != '\n' && c != '\r' && c != ESCAPE;
wclrtoeol(win), wrefresh(win))
{
if (c == -1)
continue;
else if (c == erasechar()) /* process erase character */
{
if (sp > buf)
{
sp--;
for (i = (int) strlen(unctrl(*sp)); i; i--)
waddch(win, '\b');
}
continue;
}
else if (c == killchar()) /* process kill character */
{
sp = buf;
wmove(win, oy, ox);
continue;
}
else if (sp == buf)
{
if (c == '-' && win != stdscr)
break;
else if (c == '~')
{
strcpy(buf, home);
waddstr(win, home);
sp += strlen(home);
continue;
}
}
if (sp >= &buf[MAXINP] || !(isprint(c) || c == ' '))
putchar(CTRL('G'));
else
{
*sp++ = c;
waddstr(win, unctrl(c));
}
}
*sp = '\0';
if (sp > buf) /* only change option if something has been typed */
strucpy(opt, buf, (int) strlen(buf));
mvwprintw(win, oy, ox, "%s\n", opt);
wrefresh(win);
if (win == stdscr)
mpos += (int)(sp - buf);
if (c == '-')
return MINUS;
else if (c == ESCAPE)
return QUIT;
else
return NORM;
}
/*
* get_inv_t
* Get an inventory type name
*/
int
get_inv_t(void *vp, WINDOW *win)
{
int *ip = (int *) vp;
int oy, ox;
bool op_bad;
op_bad = TRUE;
getyx(win, oy, ox);
waddstr(win, inv_t_name[*ip]);
while (op_bad)
{
wmove(win, oy, ox);
wrefresh(win);
switch (readchar())
{
case 'o':
case 'O':
*ip = INV_OVER;
op_bad = FALSE;
break;
case 's':
case 'S':
*ip = INV_SLOW;
op_bad = FALSE;
break;
case 'c':
case 'C':
*ip = INV_CLEAR;
op_bad = FALSE;
break;
case '\n':
case '\r':
op_bad = FALSE;
break;
case ESCAPE:
return QUIT;
case '-':
return MINUS;
default:
wmove(win, oy, ox + 15);
waddstr(win, "(O, S, or C)");
}
}
mvwprintw(win, oy, ox, "%s\n", inv_t_name[*ip]);
return NORM;
}
#ifdef MASTER
/*
* get_num:
* Get a numeric option
*/
int
get_num(void *vp, WINDOW *win)
{
short *opt = (short *) vp;
int i;
static char buf[MAXSTR];
if ((i = get_str(buf, win)) == NORM)
*opt = (short) atoi(buf);
return i;
}
#endif
/*
* parse_opts:
* Parse options from string, usually taken from the environment.
* The string is a series of comma seperated values, with booleans
* being stated as "name" (true) or "noname" (false), and strings
* being "name=....", with the string being defined up to a comma
* or the end of the entire option string.
*/
void
parse_opts(char *str)
{
char *sp;
OPTION *op;
int len;
char **i;
char *start;
while (*str)
{
/*
* Get option name
*/
for (sp = str; isalpha(*sp); sp++)
continue;
len = (int)(sp - str);
/*
* Look it up and deal with it
*/
for (op = optlist; op <= &optlist[NUM_OPTS-1]; op++)
if (EQSTR(str, op->o_name, len))
{
if (op->o_putfunc == put_bool) /* if option is a boolean */
*(bool *)op->o_opt = TRUE; /* NOSTRICT */
else /* string option */
{
/*
* Skip to start of string value
*/
for (str = sp + 1; *str == '='; str++)
continue;
if (*str == '~')
{
strcpy((char *) op->o_opt, home); /* NOSTRICT */
start = (char *) op->o_opt + strlen(home);/* NOSTRICT */
while (*++str == '/')
continue;
}
else
start = (char *) op->o_opt; /* NOSTRICT */
/*
* Skip to end of string value
*/
for (sp = str + 1; *sp && *sp != ','; sp++)
continue;
/*
* check for type of inventory
*/
if (op->o_putfunc == put_inv_t)
{
if (islower(*str))
*str = (char) toupper(*str);
for (i = inv_t_name; i <= &inv_t_name[INV_CLEAR]; i++)
if (strncmp(str, *i, sp - str) == 0)
{
inv_type = (int)(i - inv_t_name);
break;
}
}
else
strucpy(start, str, (int)(sp - str));
}
break;
}
/*
* check for "noname" for booleans
*/
else if (op->o_putfunc == put_bool
&& EQSTR(str, "no", 2) && EQSTR(str + 2, op->o_name, len - 2))
{
*(bool *)op->o_opt = FALSE; /* NOSTRICT */
break;
}
/*
* skip to start of next option name
*/
while (*sp && !isalpha(*sp))
sp++;
str = sp;
}
}
/*
* strucpy:
* Copy string using unctrl for things
*/
void
strucpy(char *s1, char *s2, int len)
{
if (len > MAXINP)
len = MAXINP;
while (len--)
{
if (isprint(*s2) || *s2 == ' ')
*s1++ = *s2;
s2++;
}
*s1 = '\0';
}

503
pack.c
View File

@@ -1,503 +0,0 @@
/*
* Routines to deal with the pack
*
* @(#)pack.c 4.40 (Berkeley) 02/05/99
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <string.h>
#include <curses.h>
#include <ctype.h>
#include "rogue.h"
/*
* add_pack:
* Pick up an object and add it to the pack. If the argument is
* non-null use it as the linked_list pointer instead of gettting
* it off the ground.
*/
void
add_pack(THING *obj, bool silent)
{
THING *op, *lp;
bool from_floor;
from_floor = FALSE;
if (obj == NULL)
{
if ((obj = find_obj(hero.y, hero.x)) == NULL)
return;
from_floor = TRUE;
}
/*
* Check for and deal with scare monster scrolls
*/
if (obj->o_type == SCROLL && obj->o_which == S_SCARE)
if (obj->o_flags & ISFOUND)
{
detach(lvl_obj, obj);
mvaddch(hero.y, hero.x, floor_ch());
chat(hero.y, hero.x) = (proom->r_flags & ISGONE) ? PASSAGE : FLOOR;
discard(obj);
msg("the scroll turns to dust as you pick it up");
return;
}
if (pack == NULL)
{
pack = obj;
obj->o_packch = pack_char();
inpack++;
}
else
{
lp = NULL;
for (op = pack; op != NULL; op = next(op))
{
if (op->o_type != obj->o_type)
lp = op;
else
{
while (op->o_type == obj->o_type && op->o_which != obj->o_which)
{
lp = op;
if (next(op) == NULL)
break;
else
op = next(op);
}
if (op->o_type == obj->o_type && op->o_which == obj->o_which)
{
if (ISMULT(op->o_type))
{
if (!pack_room(from_floor, obj))
return;
op->o_count++;
dump_it:
discard(obj);
obj = op;
lp = NULL;
goto out;
}
else if (obj->o_group)
{
lp = op;
while (op->o_type == obj->o_type
&& op->o_which == obj->o_which
&& op->o_group != obj->o_group)
{
lp = op;
if (next(op) == NULL)
break;
else
op = next(op);
}
if (op->o_type == obj->o_type
&& op->o_which == obj->o_which
&& op->o_group == obj->o_group)
{
op->o_count += obj->o_count;
inpack--;
if (!pack_room(from_floor, obj))
return;
goto dump_it;
}
}
else
lp = op;
}
out:
break;
}
}
if (lp != NULL)
{
if (!pack_room(from_floor, obj))
return;
else
{
obj->o_packch = pack_char();
next(obj) = next(lp);
prev(obj) = lp;
if (next(lp) != NULL)
prev(next(lp)) = obj;
next(lp) = obj;
}
}
}
obj->o_flags |= ISFOUND;
/*
* If this was the object of something's desire, that monster will
* get mad and run at the hero.
*/
for (op = mlist; op != NULL; op = next(op))
if (op->t_dest == &obj->o_pos)
op->t_dest = &hero;
if (obj->o_type == AMULET)
amulet = TRUE;
/*
* Notify the user
*/
if (!silent)
{
if (!terse)
addmsg("you now have ");
msg("%s (%c)", inv_name(obj, !terse), obj->o_packch);
}
}
/*
* pack_room:
* See if there's room in the pack. If not, print out an
* appropriate message
*/
bool
pack_room(bool from_floor, THING *obj)
{
if (++inpack > MAXPACK)
{
if (!terse)
addmsg("there's ");
addmsg("no room");
if (!terse)
addmsg(" in your pack");
endmsg();
if (from_floor)
move_msg(obj);
inpack = MAXPACK;
return FALSE;
}
if (from_floor)
{
detach(lvl_obj, obj);
mvaddch(hero.y, hero.x, floor_ch());
chat(hero.y, hero.x) = (proom->r_flags & ISGONE) ? PASSAGE : FLOOR;
}
return TRUE;
}
/*
* leave_pack:
* take an item out of the pack
*/
THING *
leave_pack(THING *obj, bool newobj, bool all)
{
THING *nobj;
inpack--;
nobj = obj;
if (obj->o_count > 1 && !all)
{
last_pick = obj;
obj->o_count--;
if (obj->o_group)
inpack++;
if (newobj)
{
nobj = new_item();
*nobj = *obj;
next(nobj) = NULL;
prev(nobj) = NULL;
nobj->o_count = 1;
}
}
else
{
last_pick = NULL;
pack_used[obj->o_packch - 'a'] = FALSE;
detach(pack, obj);
}
return nobj;
}
/*
* pack_char:
* Return the next unused pack character.
*/
char
pack_char()
{
bool *bp;
for (bp = pack_used; *bp; bp++)
continue;
*bp = TRUE;
return (char)((int)(bp - pack_used) + 'a');
}
/*
* inventory:
* List what is in the pack. Return TRUE if there is something of
* the given type.
*/
bool
inventory(THING *list, int type)
{
static char inv_temp[MAXSTR];
n_objs = 0;
for (; list != NULL; list = next(list))
{
if (type && type != list->o_type && !(type == CALLABLE &&
list->o_type != FOOD && list->o_type != AMULET) &&
!(type == R_OR_S && (list->o_type == RING || list->o_type == STICK)))
continue;
n_objs++;
#ifdef MASTER
if (!list->o_packch)
strcpy(inv_temp, "%s");
else
#endif
sprintf(inv_temp, "%c) %%s", list->o_packch);
msg_esc = TRUE;
if (add_line(inv_temp, inv_name(list, FALSE)) == ESCAPE)
{
msg_esc = FALSE;
msg("");
return TRUE;
}
msg_esc = FALSE;
}
if (n_objs == 0)
{
if (terse)
msg(type == 0 ? "empty handed" :
"nothing appropriate");
else
msg(type == 0 ? "you are empty handed" :
"you don't have anything appropriate");
return FALSE;
}
end_line();
return TRUE;
}
/*
* pick_up:
* Add something to characters pack.
*/
void
pick_up(char ch)
{
THING *obj;
if (on(player, ISLEVIT))
return;
obj = find_obj(hero.y, hero.x);
if (move_on)
move_msg(obj);
else
switch (ch)
{
case GOLD:
if (obj == NULL)
return;
money(obj->o_goldval);
detach(lvl_obj, obj);
discard(obj);
proom->r_goldval = 0;
break;
default:
#ifdef MASTER
debug("Where did you pick a '%s' up???", unctrl(ch));
#endif
case ARMOR:
case POTION:
case FOOD:
case WEAPON:
case SCROLL:
case AMULET:
case RING:
case STICK:
add_pack((THING *) NULL, FALSE);
break;
}
}
/*
* move_msg:
* Print out the message if you are just moving onto an object
*/
void
move_msg(THING *obj)
{
if (!terse)
addmsg("you ");
msg("moved onto %s", inv_name(obj, TRUE));
}
/*
* picky_inven:
* Allow player to inventory a single item
*/
void
picky_inven()
{
THING *obj;
char mch;
if (pack == NULL)
msg("you aren't carrying anything");
else if (next(pack) == NULL)
msg("a) %s", inv_name(pack, FALSE));
else
{
msg(terse ? "item: " : "which item do you wish to inventory: ");
mpos = 0;
if ((mch = readchar()) == ESCAPE)
{
msg("");
return;
}
for (obj = pack; obj != NULL; obj = next(obj))
if (mch == obj->o_packch)
{
msg("%c) %s", mch, inv_name(obj, FALSE));
return;
}
msg("'%s' not in pack", unctrl(mch));
}
}
/*
* get_item:
* Pick something out of a pack for a purpose
*/
THING *
get_item(char *purpose, int type)
{
THING *obj;
char ch;
if (pack == NULL)
msg("you aren't carrying anything");
else if (again)
if (last_pick)
return last_pick;
else
msg("you ran out");
else
{
for (;;)
{
if (!terse)
addmsg("which object do you want to ");
addmsg(purpose);
if (terse)
addmsg(" what");
msg("? (* for list): ");
ch = readchar();
mpos = 0;
/*
* Give the poor player a chance to abort the command
*/
if (ch == ESCAPE)
{
reset_last();
after = FALSE;
msg("");
return NULL;
}
n_objs = 1; /* normal case: person types one char */
if (ch == '*')
{
mpos = 0;
if (inventory(pack, type) == 0)
{
after = FALSE;
return NULL;
}
continue;
}
for (obj = pack; obj != NULL; obj = next(obj))
if (obj->o_packch == ch)
break;
if (obj == NULL)
{
msg("'%s' is not a valid item",unctrl(ch));
continue;
}
else
return obj;
}
}
return NULL;
}
/*
* money:
* Add or subtract gold from the pack
*/
void
money(int value)
{
purse += value;
mvaddch(hero.y, hero.x, floor_ch());
chat(hero.y, hero.x) = (proom->r_flags & ISGONE) ? PASSAGE : FLOOR;
if (value > 0)
{
if (!terse)
addmsg("you found ");
msg("%d gold pieces", value);
}
}
/*
* floor_ch:
* Return the appropriate floor character for her room
*/
char
floor_ch()
{
if (proom->r_flags & ISGONE)
return PASSAGE;
return (show_floor() ? FLOOR : ' ');
}
/*
* floor_at:
* Return the character at hero's position, taking see_floor
* into account
*/
char
floor_at()
{
char ch;
ch = chat(hero.y, hero.x);
if (ch == FLOOR)
ch = floor_ch();
return ch;
}
/*
* reset_last:
* Reset the last command when the current one is aborted
*/
void
reset_last()
{
last_comm = l_last_comm;
last_dir = l_last_dir;
last_pick = l_last_pick;
}

View File

@@ -1,424 +0,0 @@
/*
* Draw the connecting passages
*
* @(#)passages.c 4.22 (Berkeley) 02/05/99
*
* Rogue: Exploring the Dungeons of Doom
* Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman
* All rights reserved.
*
* See the file LICENSE.TXT for full copyright and licensing information.
*/
#include <stdlib.h>
#include <curses.h>
#include "rogue.h"
/*
* do_passages:
* Draw all the passages on a level.
*/
void
do_passages()
{
struct rdes *r1, *r2 = NULL;
int i, j;
int roomcount;
static struct rdes
{
bool conn[MAXROOMS]; /* possible to connect to room i? */
bool isconn[MAXROOMS]; /* connection been made to room i? */
bool ingraph; /* this room in graph already? */
} rdes[MAXROOMS] = {
{ { 0, 1, 0, 1, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 0 },
{ { 1, 0, 1, 0, 1, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 0 },
{ { 0, 1, 0, 0, 0, 1, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 0 },
{ { 1, 0, 0, 0, 1, 0, 1, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 0 },
{ { 0, 1, 0, 1, 0, 1, 0, 1, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 0 },
{ { 0, 0, 1, 0, 1, 0, 0, 0, 1 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 0 },
{ { 0, 0, 0, 1, 0, 0, 0, 1, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 0 },
{ { 0, 0, 0, 0, 1, 0, 1, 0, 1 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 0 },
{ { 0, 0, 0, 0, 0, 1, 0, 1, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 0 },
};
/*
* reinitialize room graph description
*/
for (r1 = rdes; r1 <= &rdes[MAXROOMS-1]; r1++)
{
for (j = 0; j < MAXROOMS; j++)
r1->isconn[j] = FALSE;
r1->ingraph = FALSE;
}
/*
* starting with one room, connect it to a random adjacent room and
* then pick a new room to start with.
*/
roomcount = 1;
r1 = &rdes[rnd(MAXROOMS)];
r1->ingraph = TRUE;
do
{
/*
* find a room to connect with
*/
j = 0;
for (i = 0; i < MAXROOMS; i++)
if (r1->conn[i] && !rdes[i].ingraph && rnd(++j) == 0)
r2 = &rdes[i];
/*
* if no adjacent rooms are outside the graph, pick a new room
* to look from
*/
if (j == 0)
{
do
r1 = &rdes[rnd(MAXROOMS)];
until (r1->ingraph);
}
/*
* otherwise, connect new room to the graph, and draw a tunnel
* to it
*/
else
{
r2->ingraph = TRUE;
i = (int)(r1 - rdes);
j = (int)(r2 - rdes);
conn(i, j);
r1->isconn[j] = TRUE;
r2->isconn[i] = TRUE;
roomcount++;
}
} while (roomcount < MAXROOMS);
/*
* attempt to add passages to the graph a random number of times so
* that there isn't always just one unique passage through it.
*/
for (roomcount = rnd(5); roomcount > 0; roomcount--)
{
r1 = &rdes[rnd(MAXROOMS)]; /* a random room to look from */
/*
* find an adjacent room not already connected
*/
j = 0;
for (i = 0; i < MAXROOMS; i++)
if (r1->conn[i] && !r1->isconn[i] && rnd(++j) == 0)
r2 = &rdes[i];
/*
* if there is one, connect it and look for the next added
* passage
*/
if (j != 0)
{
i = (int)(r1 - rdes);
j = (int)(r2 - rdes);
conn(i, j);
r1->isconn[j] = TRUE;
r2->isconn[i] = TRUE;
}
}
passnum();
}
/*
* conn:
* Draw a corridor from a room in a certain direction.
*/
void
conn(int r1, int r2)
{
struct room *rpf, *rpt = NULL;
int rmt;
int distance = 0, turn_spot, turn_distance = 0;
int rm;
char direc;
static coord del, curr, turn_delta, spos, epos;
if (r1 < r2)
{
rm = r1;
if (r1 + 1 == r2)
direc = 'r';
else
direc = 'd';
}
else
{
rm = r2;
if (r2 + 1 == r1)
direc = 'r';
else
direc = 'd';
}
rpf = &rooms[rm];
/*
* Set up the movement variables, in two cases:
* first drawing one down.
*/
if (direc == 'd')
{
rmt = rm + 3; /* room # of dest */
rpt = &rooms[rmt]; /* room pointer of dest */
del.x = 0; /* direction of move */
del.y = 1;
spos.x = rpf->r_pos.x; /* start of move */
spos.y = rpf->r_pos.y;
epos.x = rpt->r_pos.x; /* end of move */
epos.y = rpt->r_pos.y;
if (!(rpf->r_flags & ISGONE)) /* if not gone pick door pos */
do
{
spos.x = rpf->r_pos.x + rnd(rpf->r_max.x - 2) + 1;
spos.y = rpf->r_pos.y + rpf->r_max.y - 1;
} while ((rpf->r_flags&ISMAZE) && !(flat(spos.y, spos.x)&F_PASS));
if (!(rpt->r_flags & ISGONE))
do
{
epos.x = rpt->r_pos.x + rnd(rpt->r_max.x - 2) + 1;
} while ((rpt->r_flags&ISMAZE) && !(flat(epos.y, epos.x)&F_PASS));
distance = abs(spos.y - epos.y) - 1; /* distance to move */
turn_delta.y = 0; /* direction to turn */
turn_delta.x = (spos.x < epos.x ? 1 : -1);
turn_distance = abs(spos.x - epos.x); /* how far to turn */
}
else if (direc == 'r') /* setup for moving right */
{
rmt = rm + 1;
rpt = &rooms[rmt];
del.x = 1;
del.y = 0;
spos.x = rpf->r_pos.x;
spos.y = rpf->r_pos.y;
epos.x = rpt->r_pos.x;
epos.y = rpt->r_pos.y;
if (!(rpf->r_flags & ISGONE))
do
{
spos.x = rpf->r_pos.x + rpf->r_max.x - 1;
spos.y = rpf->r_pos.y + rnd(rpf->r_max.y - 2) + 1;
} while ((rpf->r_flags&ISMAZE) && !(flat(spos.y, spos.x)&F_PASS));
if (!(rpt->r_flags & ISGONE))
do
{
epos.y = rpt->r_pos.y + rnd(rpt->r_max.y - 2) + 1;
} while ((rpt->r_flags&ISMAZE) && !(flat(epos.y, epos.x)&F_PASS));
distance = abs(spos.x - epos.x) - 1;
turn_delta.y = (spos.y < epos.y ? 1 : -1);
turn_delta.x = 0;
turn_distance = abs(spos.y - epos.y);
}
#ifdef MASTER
else
debug("error in connection tables");
#endif
turn_spot = rnd(distance - 1) + 1; /* where turn starts */
/*
* Draw in the doors on either side of the passage or just put #'s
* if the rooms are gone.
*/
if (!(rpf->r_flags & ISGONE))
door(rpf, &spos);
else
putpass(&spos);
if (!(rpt->r_flags & ISGONE))
door(rpt, &epos);
else
putpass(&epos);
/*
* Get ready to move...
*/
curr.x = spos.x;
curr.y = spos.y;
while (distance > 0)
{
/*
* Move to new position
*/
curr.x += del.x;
curr.y += del.y;
/*
* Check if we are at the turn place, if so do the turn
*/
if (distance == turn_spot)
while (turn_distance--)
{
putpass(&curr);
curr.x += turn_delta.x;
curr.y += turn_delta.y;
}
/*
* Continue digging along
*/
putpass(&curr);
distance--;
}
curr.x += del.x;
curr.y += del.y;
if (!ce(curr, epos))
msg("warning, connectivity problem on this level");
}
/*
* putpass:
* add a passage character or secret passage here
*/
void
putpass(coord *cp)
{
PLACE *pp;
pp = INDEX(cp->y, cp->x);
pp->p_flags |= F_PASS;
if (rnd(10) + 1 < level && rnd(40) == 0)
pp->p_flags &= ~F_REAL;
else
pp->p_ch = PASSAGE;
}
/*
* door:
* Add a door or possibly a secret door. Also enters the door in
* the exits array of the room.
*/
void
door(struct room *rm, coord *cp)
{
PLACE *pp;
rm->r_exit[rm->r_nexits++] = *cp;
if (rm->r_flags & ISMAZE)
return;
pp = INDEX(cp->y, cp->x);
if (rnd(10) + 1 < level && rnd(5) == 0)
{
if (cp->y == rm->r_pos.y || cp->y == rm->r_pos.y + rm->r_max.y - 1)
pp->p_ch = '-';
else
pp->p_ch = '|';
pp->p_flags &= ~F_REAL;
}
else
pp->p_ch = DOOR;
}
#ifdef MASTER
/*
* add_pass:
* Add the passages to the current window (wizard command)
*/
void
add_pass()
{
PLACE *pp;
int y, x;
char ch;
for (y = 1; y < NUMLINES - 1; y++)
for (x = 0; x < NUMCOLS; x++)
{
pp = INDEX(y, x);
if ((pp->p_flags & F_PASS) || pp->p_ch == DOOR ||
(!(pp->p_flags&F_REAL) && (pp->p_ch == '|' || pp->p_ch == '-')))
{
ch = pp->p_ch;
if (pp->p_flags & F_PASS)
ch = PASSAGE;
pp->p_flags |= F_SEEN;
move(y, x);
if (pp->p_monst != NULL)
pp->p_monst->t_oldch = pp->p_ch;
else if (pp->p_flags & F_REAL)
addch(ch);
else
{
standout();
addch((pp->p_flags & F_PASS) ? PASSAGE : DOOR);
standend();
}
}
}
}
#endif
/*
* passnum:
* Assign a number to each passageway
*/
static int pnum;
static bool newpnum;
void
passnum()
{
struct room *rp;
int i;
pnum = 0;
newpnum = FALSE;
for (rp = passages; rp < &passages[MAXPASS]; rp++)
rp->r_nexits = 0;
for (rp = rooms; rp < &rooms[MAXROOMS]; rp++)
for (i = 0; i < rp->r_nexits; i++)
{
newpnum++;
numpass(rp->r_exit[i].y, rp->r_exit[i].x);
}
}
/*
* numpass:
* Number a passageway square and its brethren
*/
void
numpass(int y, int x)
{
char *fp;
struct room *rp;
char ch;
if (x >= NUMCOLS || x < 0 || y >= NUMLINES || y <= 0)
return;
fp = &flat(y, x);
if (*fp & F_PNUM)
return;
if (newpnum)
{
pnum++;
newpnum = FALSE;
}
/*
* check to see if it is a door or secret door, i.e., a new exit,
* or a numerable type of place
*/
if ((ch = chat(y, x)) == DOOR ||
(!(*fp & F_REAL) && (ch == '|' || ch == '-')))
{
rp = &passages[pnum];
rp->r_exit[rp->r_nexits].y = y;
rp->r_exit[rp->r_nexits++].x = x;
}
else if (!(*fp & F_PASS))
return;
*fp |= pnum;
/*
* recurse on the surrounding places
*/
numpass(y + 1, x);
numpass(y - 1, x);
numpass(y, x + 1);
numpass(y, x - 1);
}

Some files were not shown because too many files have changed in this diff Show More