74 Commits

Author SHA1 Message Date
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
57 changed files with 9967 additions and 5656 deletions

38
.golangci.yml Normal file
View File

@@ -0,0 +1,38 @@
version: "2"
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
# Repo-specific exceptions approved by sneak (2026-07-06)
- paralleltest # Requires t.Parallel() in every test
# Approved by sneak 2026-07-07
- testpackage # Tests use internal package game to reach unexported state
- exhaustive # C-faithful switches handle only the cases C handled
- mnd # C-faithful gameplay literals; naming them hurts C-greppability
linters-settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

File diff suppressed because it is too large Load Diff

41
MEMORY.md Normal file
View File

@@ -0,0 +1,41 @@
# 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. The game already unwinds C's exit()
calls via a gameEnd panic recovered in Run. Return errors where a caller
genuinely handles them (save-file prompts, restore validation). Reserve
deliberate `_ =` discards for true best-effort paths (scorefile writes,
signal-time autosave), always with a comment saying why.
## Linting
The .golangci.yml is the house standard and may only be modified with sneak's
explicit permission. To disable a linter, ask, explaining what the linter does;
he approves specific exceptions, which are recorded in the config's
"Repo-specific exceptions" block with the approval date. Approved so far:
paralleltest (2026-07-06); testpackage, exhaustive, and mnd (2026-07-07). 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 plain `go test -v`;
no throwaway scratch scripts. Successful debug probes become regression tests.

35
Makefile Normal file
View File

@@ -0,0 +1,35 @@
# Development convenience targets. This repo is exempt from the standard
# policy scaffold (no Dockerfile, CI, or REPO_POLICIES.md); this Makefile
# is only a thin wrapper around the Go toolchain, golangci-lint, 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
.PHONY: check fmt fmt-check lint test
# Format, lint, and test — the full local pre-commit gate.
check: fmt-check lint test
# 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 (config in .golangci.yml).
lint:
golangci-lint run $(GO_PKGS)
# Run the test suite.
test:
go test $(GO_PKGS)

View File

@@ -2,19 +2,19 @@
[![License](https://img.shields.io/badge/license-BSD-blue.svg)](LICENSE.TXT)
**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.
**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
(19801983, 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.
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.
## Building and running
@@ -40,13 +40,12 @@ go build ./cmd/rogue
Press `?` in game for the full list.
- **arrows** or **h/j/k/l/y/u/b/n** — move (shift to run, ctrl to run
until adjacent)
- **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
- **`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
@@ -61,8 +60,8 @@ export ROGUEOPTS="name=YourName,terse,jump,fruit=mango"
ROGUE_WIZARD=1 SEED=12345 ./rogue
```
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob
snapshots and, as in the original, are deleted when restored.
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob snapshots
and, as in the original, are deleted when restored.
## Code layout
@@ -73,13 +72,17 @@ term/ tcell-backed terminal, replacing curses
cmd/rogue/ the executable
```
The engine package is fully headless-testable: `go test ./game/` runs
scripted game sessions, dungeon-generation golden checks, and an RNG
compatibility test against the original C generator.
The engine package is fully headless-testable: `go test ./game/` 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` (golangci-lint), `make test`, and `make check` (all
three).
## License
BSD-style; see [LICENSE.TXT](LICENSE.TXT).
Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn
Wichman. All rights reserved.
Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman.
All rights reserved.

268
TODO.md
View File

@@ -1,120 +1,188 @@
# 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
- 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.
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.
- 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
Refactor step 1 (branch refactor/descriptive-constants): rename
constants to descriptive, less C-like names. Creature/object/room/place
flag bits (IsHuh→Confused, IsHalu→Hallucinating, CanHuh→CanConfuse,
SeeMonst→SenseMonsters, ObjIsFound→WasFound, IsCanc→Cancelled, ...),
trap types (TMyst→TrapMystery), item subtype constants
(PLSD→PotionLSD, SIDRorS→ScrollIdentifyRingOrStick,
RAddHit→RingDexterity, WsHasteM→WandHasteMonster,
TwoSword→WeaponTwoHandedSword, ...), Max* count constants →
Num*Types, and the Level.NTraps field → TrapCount. Pure rename; no
behavior change; tests unchanged.
Broaden unit test coverage where playtesting finds thin spots (rings, sticks,
wizard commands).
# Completed Steps
- 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
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. Refactor step 2: typed kind enums — PotionKind, ScrollKind,
RingKind, WandKind, WeaponKind, ArmorKind, TrapKind as iota enums
with fmt.Stringer; Object.Which and the ObjInfo tables move to the
typed kinds; the object category glyph bytes ('!', '?', ...) become
an ObjectKind with a Glyph() method so item category and map
character stop sharing one byte namespace.
2. Refactor step 3: un-overload Object fields — split Object.Arm into
ArmorClass/Charges/GoldValue; parse damage dice ("1x4/1x2") once
into a DiceSpec type at table definition instead of per swing;
save-format update with round-trip test migration.
3. Adopt the house Go linting standards: copy .golangci.yml from the
prompts repo and bring game/, term/, and cmd/ lint-clean (the port
is greenfield code, so no exemptions apply).
4. Refactor step 4: method renames, movement/world subsystem
(doMove→moveHero, beTrapped→springTrap, rndmove→randomStep,
doRooms/doPassages/doMaze→digRooms/digPassages/digMaze,
chgStr→changeStrength, ...); remove the goto/label flows in doMove,
dispatch, and saveGame in favor of loops and helpers.
5. Refactor step 5: method renames, items/combat/UI subsystems
(invName→inventoryName, rollEm→rollAttacks, doPot→applyPotionFuse,
getItem→promptPackItem returning (obj, ok), getDir→promptDirection);
int status codes (attack returning -1) become named results. Two or
three commits, one subsystem each.
6. Refactor step 6: extract types from the god object — MessageLine
owns the msg/addmsg/endmsg machinery; pack/inventory operations move
onto *Player; monster/object list management and map queries
consolidate onto *Level; RogueGame keeps turn orchestration and
cross-system effects only.
7. Refactor step 7: effects dispatch — the giant quaff/readScroll/doZap
switches become per-kind handler tables of small named methods,
keeping effect order and RNG call sequence identical.
8. Refactor step 8: constructor and style pass per the house
styleguide — game.New(game.Params{...}) replacing NewGame(Config);
replace the gameEnd panic unwind with error-based turn results where
feasible; 77-column wrap sweep.
9. Docs refresh: update ARCHITECTURE.md Part 2 and README.md for the
post-refactor names; add the C name → Go name rename table.
10. Playtest hardening pass: play several full games with the tcell
binary and extend run_test.go to script a deeper multi-level
playthrough (descend past level 5, use potions, scrolls, zapping,
save/restore). Fix any panics, message mismatches, or divergences
from the C behavior that this uncovers, with regression tests.
11. Verify the seed-compatibility claim against the C reference on
c-master: same seed, same dungeon, same item tables, for several
seeds.
12. Broaden unit test coverage where playtesting finds thin spots
(rings, sticks, wizard commands).
13. Tag a release once a full game (Amulet retrieval and score entry)
completes without defects.
14. 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.
15. Note: this repo is exempt from the standard policy scaffold. Do not
add Makefile, Dockerfile, or REPO_POLICIES.md.
1. Tag a release once a full game (Amulet retrieval and score entry) completes
without defects.
2. Full-terminal-size support (deferred by explicit decision 2026-07-06):
per-game dungeon dimensions instead of the 80x24 constants; open design
questions are resize policy, gameplay tuning at larger sizes, and a --classic
80x24 mode.
3. Note: this repo is exempt from the standard policy scaffold. A minimal dev
Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's
2026-07-07 request, but do not add a Dockerfile, CI config, or
REPO_POLICIES.md.

View File

@@ -13,89 +13,121 @@ import (
"syscall"
"time"
"sneak.berlin/go/rogue/game"
"sneak.berlin/go/rogue/term"
"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()
home, _ := os.UserHomeDir()
// get options from environment (main.c)
rogueOpts := os.Getenv("ROGUEOPTS")
name := ""
if u, err := user.Current(); err == nil {
name = u.Username
}
wizard := os.Getenv("ROGUE_WIZARD") != ""
// dungeon number: SEED for reproducible dungeons (wizard mode in C),
// else time+pid
var seed int32
if env := os.Getenv("SEED"); env != "" && wizard {
n, _ := strconv.Atoi(env)
seed = int32(n)
} else {
seed = int32(time.Now().Unix()) + int32(os.Getpid())
}
cfg := game.Config{
Seed: seed,
Name: name,
RogueOpts: rogueOpts,
Home: home,
ScorePath: home + "/.rogue.scores",
Wizard: wizard,
}
params := loadParams()
if *scores {
g := game.NewGame(cfg)
g.ShowScores()
return
game.New(params).ShowScores()
return 0
}
t, err := term.New()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
return 1
}
defer t.Fini()
cfg.Term = 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], cfg)
g, err = game.Restore(args[0], params)
if err != nil {
t.Fini()
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
fmt.Fprintln(os.Stderr, err) // deferred Fini restores the terminal
return 1
}
} else {
g = game.NewGame(cfg)
g = game.New(params)
}
if *deathDemo {
g.DeathDemo()
return
g.DeathDemo() // does not return: death exits the process
return 0
}
// SIGHUP/SIGTERM autosave (save.c auto_save)
installAutosave(g, t)
g.Run() // does not return: the game ends by exiting the process
return 0
}
// 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,
}
}
// installAutosave saves the game and exits on SIGHUP/SIGTERM (save.c
// auto_save).
func installAutosave(g *game.RogueGame, t *term.Tcell) {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGHUP, syscall.SIGTERM)
go func() {
<-sig
g.AutoSave()
t.Fini()
os.Exit(0)
}()
if err := g.Run(); err != nil {
t.Fini()
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// 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.
return int32(time.Now().Unix()&0x7fffffff) +
int32(os.Getpid()&0x7fffffff)
}

View File

@@ -5,36 +5,47 @@ package game
// wear lets the player put armor on (armor.c wear).
func (g *RogueGame) wear() {
p := &g.Player
obj := g.getItem("wear", int(Armor))
if obj == nil {
obj, ok := g.promptPackItem("wear", KindArmor)
if !ok {
return
}
if p.CurArmor != nil {
g.addmsg("you are already wearing some")
g.addmsgf("you are already wearing some")
if !g.Options.Terse {
g.addmsg(". You'll have to take it off first")
g.addmsgf(". You'll have to take it off first")
}
g.endmsg()
g.After = false
return
}
if obj.Type != Armor {
if obj.Kind != KindArmor {
g.msg("you can't wear that")
return
}
g.wasteTime()
obj.Flags.Set(IsKnow)
sp := g.invName(obj, true)
obj.Flags.Set(Known)
sp := g.inventoryName(obj, true)
p.CurArmor = obj
if !g.Options.Terse {
g.addmsg("you are now ")
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
@@ -43,18 +54,23 @@ func (g *RogueGame) takeOff() {
} else {
g.msg("you aren't wearing any armor")
}
return
}
if !g.dropCheck(p.CurArmor) {
return
}
p.CurArmor = nil
if g.Options.Terse {
g.addmsg("was")
g.addmsgf("was")
} else {
g.addmsg("you used to be")
g.addmsgf("you used to be")
}
g.msg(" wearing %c) %s", obj.PackCh, g.invName(obj, true))
g.msg(" wearing %c) %s", obj.PackCh, g.inventoryName(obj, true))
}
// wasteTime does nothing but let other things happen (armor.c waste_time).

View File

@@ -9,44 +9,61 @@ const dragonShot = 5
func (g *RogueGame) runners(int) {
list := append([]*Monster(nil), g.Level.Monsters...)
for _, tp := range list {
if !tp.On(IsHeld) && tp.On(IsRun) {
origPos := tp.Pos
wastarget := tp.On(IsTarget)
if g.moveMonst(tp) == -1 {
continue
}
if tp.On(IsFly) && distCp(g.Player.Pos, tp.Pos) >= 3 {
if g.moveMonst(tp) == -1 {
continue
}
}
if wastarget && origPos != tp.Pos {
tp.Flags.Clear(IsTarget)
g.ToDeath = false
}
}
g.runnerTurn(tp)
}
if g.HasHit {
g.endmsg()
g.HasHit = false
}
}
// moveMonst executes a single turn of running for a monster (chase.c
// move_monst). Returns -1 if the monster died or left the level.
func (g *RogueGame) moveMonst(tp *Monster) int {
if !tp.On(IsSlow) || tp.Turn {
if g.doChase(tp) == -1 {
return -1
// 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 tp.On(IsHaste) {
if g.doChase(tp) == -1 {
return -1
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 0
return false
}
// relocate makes the monster's new location be the specified one, updating
@@ -54,52 +71,104 @@ func (g *RogueGame) moveMonst(tp *Monster) int {
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.setOldch(th, newLoc)
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(SeeMonst) {
} else if g.Player.On(SenseMonsters) {
g.standout()
g.addch(th.Type)
g.standend()
}
}
// doChase makes one thing chase another (chase.c do_chase). Returns -1 if
// the chaser died in the attempt.
func (g *RogueGame) doChase(th *Monster) int {
p := &g.Player
// 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
mindist := 32767
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(IsGreed) && rer.GoldVal == 0 {
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)
ree = g.roomIn(*th.Dest)
}
// We don't count doors as inside rooms for this routine
door := g.Level.Char(th.Pos.Y, th.Pos.X) == Door
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
over:
// 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 {
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 {
@@ -107,169 +176,206 @@ over:
mindist = curdist
}
}
if door {
rer = &g.Level.Passages[*g.Level.FlagsAt(th.Pos.Y, th.Pos.X)&FPNum]
door = false
goto over
if !door {
return this, false
}
} else {
this = *th.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.Type == 'D' && (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)) &&
distCp(th.Pos, p.Pos) <= BoltLength*BoltLength &&
!th.On(IsCanc) && g.rnd(dragonShot) == 0 {
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()
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)
}
g.fireBolt(th.Pos, &g.Delta, "flame")
g.Running = false
g.Count = 0
g.Quiet = 0
if g.ToDeath && !th.On(IsTarget) {
g.ToDeath = false
g.Kamikaze = false
}
return 0
th.Dest = g.findDest(th)
break
}
}
// 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 this == p.Pos {
return g.attack(th)
} else if this == *th.Dest {
for _, obj := range g.Level.Objects {
if th.Dest == &obj.Pos {
detachObj(&g.Level.Objects, obj)
attachObj(&th.Pack, obj)
if th.Room.Flags.Has(IsGone) {
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
}
}
if th.Type != 'F' {
stoprun = true
}
}
} else {
if th.Type == 'F' {
return 0
}
}
g.relocate(th, g.chRet)
// And stop running if need be
if stoprun && th.Pos == *th.Dest {
th.Flags.Clear(IsRun)
}
return 0
}
// 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 {
p := &g.Player
er := tp.Pos
plcnt := 1
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(IsHuh) && g.rnd(5) != 0) || (tp.Type == 'P' && g.rnd(5) == 0) ||
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.rndmove(&tp.Creature)
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(IsHuh)
tp.Flags.Clear(Confused)
}
} else {
// Otherwise, find the empty spot next to the chaser that is
// closest to the chasee. This will eventually hold where we move
// to get closer. If we can't find an empty spot, we stay where we
// are.
curdist = distCp(er, ee)
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++ {
tryp := Coord{X: x, Y: y}
if !g.diagOk(er, tryp) {
continue
}
ch := g.Level.VisibleChar(y, x)
if stepOk(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 {
var found *Object
for _, obj := range g.Level.Objects {
if y == obj.Pos.Y && x == obj.Pos.X {
found = obj
break
}
}
if found != nil && found.Which == SScare {
continue
}
}
// It can also be a Xeroc, which we shouldn't step on
if m := g.Level.MonsterAt(y, x); m != nil && m.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 := distance(y, x, ee.Y, ee.X)
if thisdist < curdist {
plcnt = 1
g.chRet = tryp
curdist = thisdist
} else if thisdist == curdist {
if plcnt++; g.rnd(plcnt) == 0 {
g.chRet = tryp
curdist = thisdist
}
}
}
}
}
curdist = g.chaseBestSpot(tp, ee)
}
return curdist != 0 && g.chRet != p.Pos
return curdist != 0 && g.chRet != g.Player.Pos
}
// setOldch sets the oldch character for the monster (chase.c set_oldch).
func (g *RogueGame) setOldch(tp *Monster, cp Coord) {
// 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(IsBlind) {
if (sch == Floor || tp.OldCh == Floor) && tp.Room.Flags.Has(IsDark) {
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)
@@ -281,44 +387,49 @@ func (g *RogueGame) setOldch(tp *Monster, cp Coord) {
// see_monst).
func (g *RogueGame) seeMonst(mp *Monster) bool {
p := &g.Player
if p.On(IsBlind) {
if p.On(Blind) {
return false
}
if mp.On(IsInvis) && !p.On(CanSee) {
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(IsDark)
return !mp.Room.Flags.Has(Dark)
}
// runto sets a monster running after the hero (chase.c runto).
func (g *RogueGame) runto(runner Coord) {
// 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(IsRun)
tp.Flags.Clear(IsHeld)
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
// 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 {
func (g *RogueGame) roomIn(cp Coord) *Room {
fp := *g.Level.FlagsAt(cp.Y, cp.X)
if fp.Has(FPass) {
return &g.Level.Passages[fp&FPNum]
if fp.Has(FPassage) {
return &g.Level.Passages[fp&FPassNum]
}
for i := range g.Level.Rooms {
@@ -330,6 +441,7 @@ func (g *RogueGame) roomin(cp Coord) *Room {
}
g.msg("in some bizarre place (%d, %d)", cp.Y, cp.X)
return nil
}
@@ -338,33 +450,38 @@ 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 returns true if the hero can see a certain coordinate (chase.c
// cansee).
func (g *RogueGame) cansee(y, x int) bool {
func (g *RogueGame) canSee(y, x int) bool {
p := &g.Player
if p.On(IsBlind) {
if p.On(Blind) {
return false
}
if distance(y, x, p.Pos.Y, p.Pos.X) < LampDist {
if g.Level.FlagsAt(y, x).Has(FPass) {
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(IsDark)
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
@@ -374,22 +491,29 @@ func (g *RogueGame) findDest(tp *Monster) *Coord {
if prob <= 0 || tp.Room == g.Player.Room || g.seeMonst(tp) {
return &g.Player.Pos
}
for _, obj := range g.Level.Objects {
if obj.Type == Scroll && obj.Which == SScare {
if obj.Kind == KindScroll && obj.ScrollKind() == ScrollScareMonster {
continue
}
if g.roomin(obj.Pos) == tp.Room && g.rnd(100) < prob {
claimed := false
for _, other := range g.Level.Monsters {
if other.Dest == &obj.Pos {
claimed = true
break
}
}
if !claimed {
return &obj.Pos
}
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
}

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@ type Monster struct {
// 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)
@@ -40,15 +41,56 @@ type Player struct {
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, ring int) bool {
return p.CurRing[hand] != nil && p.CurRing[hand].Which == ring
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 int) bool {
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...)
@@ -59,6 +101,7 @@ func detachMon(list *[]*Monster, item *Monster) {
for i, m := range *list {
if m == item {
*list = append((*list)[:i], (*list)[i+1:]...)
return
}
}

View File

@@ -10,6 +10,10 @@ package game
// 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
@@ -21,12 +25,10 @@ const (
DUnconfuse DaemonID = 7
DUnsee DaemonID = 8
DSight DaemonID = 9
// Fuses beyond the C save map (state.c never saved these; the Go save
// format handles them uniformly).
DVisuals DaemonID = 10
DComeDown DaemonID = 11
DLand DaemonID = 12
DTurnSee DaemonID = 13 // potions.c casts turn_see to a fuse callback
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).
@@ -59,6 +61,7 @@ func (g *RogueGame) dSlot() *delayedAction {
return &g.Daemons.List[i]
}
}
panic("ran out of fuse slots") // C: debug message in MASTER, NULL deref otherwise
}
@@ -70,6 +73,7 @@ func (g *RogueGame) findSlot(f DaemonID) *delayedAction {
return d
}
}
return nil
}

View File

@@ -5,38 +5,14 @@ package game
// runDaemon invokes the callback named by id (the call through d_func in C).
func (g *RogueGame) runDaemon(id DaemonID, arg int) {
switch id {
case DRollwand:
g.rollwand(arg)
case DDoctor:
g.doctor(arg)
case DStomach:
g.stomach(arg)
case DRunners:
g.runners(arg)
case DSwander:
g.swander(arg)
case DNohaste:
g.nohaste(arg)
case DUnconfuse:
g.unconfuse(arg)
case DUnsee:
g.unsee(arg)
case DSight:
g.sight(arg)
case DVisuals:
g.visuals(arg)
case DComeDown:
g.comeDown(arg)
case DLand:
g.land(arg)
case DTurnSee:
g.turnSee(arg != 0)
default:
// Callbacks are added to this switch as their subsystems are
// ported; reaching one that isn't here is a porting bug.
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
@@ -45,6 +21,7 @@ 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 {
@@ -53,16 +30,20 @@ func (g *RogueGame) doctor(int) {
} else if g.Quiet >= 3 {
p.Stats.HP += g.rnd(lv-7) + 1
}
if p.IsRing(Left, RRegen) {
if p.IsRing(Left, RingRegeneration) {
p.Stats.HP++
}
if p.IsRing(Right, RRegen) {
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
}
}
@@ -82,6 +63,7 @@ func (g *RogueGame) rollwand(int) {
g.KillDaemon(DRollwand)
g.Fuse(DSwander, 0, wanderTime(g), Before)
}
g.Daemons.Between = 0
}
}
@@ -92,29 +74,32 @@ 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(IsHuh)
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(IsInvis) && g.seeMonst(th) {
if th.On(Invisible) && g.seeMonst(th) {
g.mvaddch(th.Pos.Y, th.Pos.X, th.OldCh)
}
}
g.Player.Flags.Clear(CanSee)
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(IsBlind) {
if p.On(Blind) {
g.Extinguish(DSight)
p.Flags.Clear(IsBlind)
if !p.Room.Flags.Has(IsGone) {
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"))
}
@@ -122,88 +107,114 @@ func (g *RogueGame) sight(int) {
// nohaste ends the hasting (daemons.c nohaste).
func (g *RogueGame) nohaste(int) {
g.Player.Flags.Clear(IsHaste)
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 {
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.addmsg("%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"))
g.stomachFaint()
} else {
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 capabilites",
"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"))
}
}
g.stomachDigest()
}
if p.HungryState != origHungry {
p.Flags.Clear(IsRun)
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(IsHalu) {
if !p.On(Hallucinating) {
return
}
g.KillDaemon(DVisuals)
p.Flags.Clear(IsHalu)
p.Flags.Clear(Hallucinating)
if p.On(IsBlind) {
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.Type)
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(SeeMonst)
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(IsInvis) || p.On(CanSee) {
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))
@@ -214,41 +225,49 @@ func (g *RogueGame) comeDown(int) {
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) {
p := &g.Player
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) {
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) {
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
seemonst := p.On(SeeMonst)
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(byte(g.rnd(26) + 'A'))
g.addch(g.randomMonsterLetter())
}
} else if seemonst {
g.standout()
g.addch(byte(g.rnd(26) + 'A'))
g.addch(g.randomMonsterLetter())
g.standend()
}
}
@@ -256,7 +275,7 @@ func (g *RogueGame) visuals(int) {
// land lands the hero from a levitation potion (daemons.c land).
func (g *RogueGame) land(int) {
g.Player.Flags.Clear(IsLevit)
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()
}

47
game/dice_test.go Normal file
View File

@@ -0,0 +1,47 @@
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) {
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) {
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)
}
}
}

View File

@@ -2,13 +2,16 @@ package game
import "testing"
// mkGameInput builds a headless game whose input plays the given script.
func mkGameInput(t *testing.T, seed int32, input string) *RogueGame {
// 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 := NewGame(Config{Seed: seed, Term: &testTerm{input: []byte(input)}})
g := New(Params{Seed: 5, Term: &testTerm{}})
g.NewLevel()
g.Oldpos = g.Player.Pos
g.Oldrp = g.roomin(g.Player.Pos)
g.Oldrp = g.roomIn(g.Player.Pos)
return g
}
@@ -16,42 +19,61 @@ func mkGameInput(t *testing.T, seed int32, input string) *RogueGame {
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) {
g := mkGameInput(t, 5, "")
g := mkGameInput(t)
pot := newObject()
pot.Type = Potion
pot.Which = PHealing
pot.Kind = KindPotion
pot.Which = int(PotionHealing)
ch := give(g, pot)
g.scr.term.(*testTerm).input = []byte{ch}
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[PHealing].Know {
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) {
g := mkGameInput(t, 5, "")
g := mkGameInput(t)
pot := newObject()
pot.Type = Potion
pot.Which = PConfuse
pot.Kind = KindPotion
pot.Which = int(PotionConfusion)
ch := give(g, pot)
g.scr.term.(*testTerm).input = []byte{ch}
setInput(t, g, ch)
g.quaff()
if !g.Player.On(IsHuh) {
if !g.Player.On(Confused) {
t.Error("confusion potion did not confuse")
}
if g.findSlot(DUnconfuse) == nil {
t.Error("no unconfuse fuse pending")
}
@@ -59,24 +81,26 @@ func TestQuaffConfusionSetsFlagAndFuse(t *testing.T) {
for range 30 {
g.DoFuses(After)
}
if g.Player.On(IsHuh) {
if g.Player.On(Confused) {
t.Error("confusion never wore off")
}
}
func TestReadEnchantArmor(t *testing.T) {
g := mkGameInput(t, 5, "")
g := mkGameInput(t)
scr := newObject()
scr.Type = Scroll
scr.Which = SArmor
scr.Kind = KindScroll
scr.Which = int(ScrollEnchantArmor)
ch := give(g, scr)
g.scr.term.(*testTerm).input = []byte{ch}
setInput(t, g, ch)
before := g.Player.CurArmor.Arm
before := g.Player.CurArmor.ArmorClass
g.readScroll()
if g.Player.CurArmor.Arm != before-1 {
if g.Player.CurArmor.ArmorClass != before-1 {
t.Errorf("enchant armor: AC %d -> %d, want %d",
before, g.Player.CurArmor.Arm, before-1)
before, g.Player.CurArmor.ArmorClass, before-1)
}
}
@@ -86,18 +110,20 @@ func TestReadHoldMonsterFreezesAdjacent(t *testing.T) {
// look(TRUE) at the end of read_scroll immediately re-wakes greedy
// monsters. The port reproduces that quirk faithfully — see
// TestHoldScrollGreedyMonsterQuirk.
g := mkGameInput(t, 5, "")
g := mkGameInput(t)
tp := spawnAdjacent(g, 'Z')
tp.Flags.Set(IsRun)
tp.Flags.Set(Awake)
scr := newObject()
scr.Type = Scroll
scr.Which = SHold
scr.Kind = KindScroll
scr.Which = int(ScrollHoldMonster)
ch := give(g, scr)
g.scr.term.(*testTerm).input = []byte{ch}
setInput(t, g, ch)
g.readScroll()
t.Logf("after scroll: flags=%o huh=%q", tp.Flags, g.Msgs.Huh)
if tp.On(IsRun) || !tp.On(IsHeld) {
if tp.On(Awake) || !tp.On(Held) {
t.Error("hold monster scroll did not hold the adjacent monster")
}
}
@@ -107,63 +133,75 @@ func TestReadHoldMonsterFreezesAdjacent(t *testing.T) {
// (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) {
g := mkGameInput(t, 5, "")
g := mkGameInput(t)
tp := spawnAdjacent(g, 'O')
tp.Flags.Set(IsRun)
tp.Flags.Set(Awake)
scr := newObject()
scr.Type = Scroll
scr.Which = SHold
scr.Kind = KindScroll
scr.Which = int(ScrollHoldMonster)
ch := give(g, scr)
g.scr.term.(*testTerm).input = []byte{ch}
setInput(t, g, ch)
g.readScroll()
t.Logf("orc after scroll: flags=%o (IsRun=%v IsHeld=%v)",
tp.Flags, tp.On(IsRun), tp.On(IsHeld))
if !tp.On(IsHeld) {
t.Error("orc lost IsHeld entirely")
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(IsRun) {
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) {
g := mkGameInput(t, 5, "")
g := mkGameInput(t)
tp := spawnAdjacent(g, 'Z')
stick := newObject()
stick.Type = Stick
stick.Which = WsSlowM
stick.Kind = KindWand
stick.Which = int(WandSlowMonster)
g.fixStick(stick)
ch := give(g, stick)
g.scr.term.(*testTerm).input = []byte{ch}
setInput(t, g, ch)
g.Delta = Coord{X: 1, Y: 0} // aim at the monster
charges := stick.Charges()
charges := stick.Charges
g.doZap()
if !tp.On(IsSlow) {
if !tp.On(Slowed) {
t.Error("slow monster wand did not slow")
}
if stick.Charges() != charges-1 {
if stick.Charges != charges-1 {
t.Error("zap did not use a charge")
}
}
func TestParseOpts(t *testing.T) {
g := NewGame(Config{Seed: 1})
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)
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,10 +6,12 @@ import "testing"
// look() state the way playit() does before the first command.
func mkGame(t *testing.T, seed int32) *RogueGame {
t.Helper()
g := NewGame(Config{Seed: seed, Term: &testTerm{}})
g := New(Params{Seed: seed, Term: &testTerm{}})
g.NewLevel()
g.Oldpos = g.Player.Pos
g.Oldrp = g.roomin(g.Player.Pos)
g.Oldrp = g.roomIn(g.Player.Pos)
return g
}
@@ -18,20 +20,22 @@ 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) {
g := mkGame(t, 42)
att := &Creature{Stats: Stats{Str: 16, Lvl: 20, Dmg: "1x4/1x4/1x4"}}
def := &Creature{Stats: Stats{Arm: 10, HP: 1000}}
def.Flags.Set(IsRun)
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.rollEm(att, def, nil, false) {
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)
@@ -45,12 +49,15 @@ func TestFightKillsMonster(t *testing.T) {
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")
}
@@ -60,46 +67,37 @@ func TestAttackHurtsPlayer(t *testing.T) {
g := mkGame(t, 9)
tp := spawnAdjacent(g, 'T') // troll: 1x8/1x8/2x6
tp.Stats.Lvl = 20 // always hits
tp.Flags.Clear(IsCanc)
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 TestDeathUnwindsWithGameEnd(t *testing.T) {
g := mkGame(t, 11)
defer func() {
r := recover()
if _, ok := r.(gameEnd); !ok {
t.Fatalf("death did not unwind with gameEnd, got %v", r)
}
if g.Playing {
t.Error("still playing after death")
}
}()
g.Options.Tombstone = false
g.death('K')
}
func TestRunnersChaseHero(t *testing.T) {
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(IsRun)
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)
}

View File

@@ -3,19 +3,19 @@ package game
// 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 [MaxPotions]string // p_colors: colors of the potions
ScrNames [MaxScrolls]string // s_names: names of the scrolls
RingStones [MaxRings]string // r_stones: stone settings of the rings
WandMade [MaxSticks]string // ws_made: what sticks are made of
WandType [MaxSticks]string // ws_type: "wand" or "staff"
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 [MaxPotions]ObjInfo
Scrolls [MaxScrolls]ObjInfo
Rings [MaxRings]ObjInfo
Sticks [MaxSticks]ObjInfo
Weapons [MaxWeapons + 1]ObjInfo
Armors [MaxArmors]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`)
}
@@ -31,9 +31,9 @@ type Options struct {
InvType int // inven: inventory style (InvOver/InvSlow/InvClear)
}
// Config carries everything needed to construct a game.
type Config struct {
Seed int32 // dungeon number; the caller derives it (time+pid or SEED env)
// 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)
@@ -44,7 +44,7 @@ type Config struct {
// 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 NewGame, then call Run.
// 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.
@@ -110,7 +110,7 @@ type RogueGame struct {
// screen / messages
scr *Screen
Msgs MsgLine
Msgs MessageLine
statusCache statusCache
invPage invPage // things.c discovery-list pagination statics
@@ -138,23 +138,27 @@ type RogueGame struct {
rogueOpts string // the ROGUEOPTS string, re-parsed by playit as in C
restored bool // game came from a save file; Run skips setup
// data is the game's copy of the static tables (extern.c and friends).
data *gameData
}
// NewGame builds a game from cfg, seeds the RNG, and randomizes the item
// 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 NewGame(cfg Config) *RogueGame {
func New(params Params) *RogueGame {
g := &RogueGame{
Rng: &Rng{Seed: cfg.Seed},
Dnum: int(cfg.Seed),
Whoami: cfg.Name,
data: newGameData(),
Rng: &Rng{Seed: params.Seed},
Dnum: int(params.Seed),
Whoami: params.Name,
Fruit: "slime-mold",
Home: cfg.Home,
Wizard: cfg.Wizard,
NoScore: cfg.Wizard,
Home: params.Home,
Wizard: params.Wizard,
NoScore: params.Wizard,
Playing: true,
Depth: 1,
ScorePath: cfg.ScorePath,
ScorePath: params.ScorePath,
LastScore: -1,
}
g.Options = Options{
@@ -164,20 +168,24 @@ func NewGame(cfg Config) *RogueGame {
}
g.InvDescribe = true
g.Msgs.SaveMsg = true
g.scr = NewScreen(cfg.Term)
g.FileName = cfg.Home + "/rogue.save"
g.rogueOpts = cfg.RogueOpts
if cfg.Wizard {
g.Player.Flags.Set(SeeMonst)
}
if cfg.RogueOpts != "" {
g.ParseOpts(cfg.RogueOpts)
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)
}
g.Monsters = monsterTable
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 = IsGone | IsDark
g.Level.Passages[i].Flags = Gone | Dark
}
g.initProbs() // set up prob tables for objects
@@ -186,34 +194,50 @@ func NewGame(cfg Config) *RogueGame {
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 returns after death, victory, quitting, or saving.
func (g *RogueGame) Run() (err error) {
defer func() {
if r := recover(); r != nil {
if _, ok := r.(gameEnd); ok {
return // normal game over / save exit
}
panic(r)
}
}()
if !g.restored {
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(). 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()
return nil
}
// 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 {
@@ -226,11 +250,7 @@ func (g *RogueGame) playit() {
}
g.Oldpos = g.Player.Pos
g.Oldrp = g.roomin(g.Player.Pos)
for g.Playing {
g.command() // command execution
}
g.endit()
g.Oldrp = g.roomIn(g.Player.Pos)
}
// endit exits the game (main.c endit).
@@ -242,7 +262,7 @@ func (g *RogueGame) endit() {
func (g *RogueGame) fatal(s string) {
g.mvaddstr(NumLines-2, 0, s)
g.refresh()
g.myExit(0)
g.myExit()
}
// quit has the player make certain, then exits (main.c quit). The final
@@ -252,17 +272,21 @@ func (g *RogueGame) quit(int) {
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.MvPrintw(NumLines-2, 0, "You quit with %d gold pieces", g.Player.Purse)
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(0)
g.myExit()
return
}
g.move(0, 0)
g.clrtoeol()
g.status()

View File

@@ -8,78 +8,84 @@ import "strings"
// initPlayer rolls her up (init.c init_player).
func (g *RogueGame) initPlayer() {
p := &g.Player
p.MaxStats = initStats
p.MaxStats = g.data.initStats
p.Stats = p.MaxStats
p.FoodLeft = HungerTime
// Give him some food
obj := newObject()
obj.Type = Food
obj.Kind = KindFood
obj.Count = 1
g.addPack(obj, true)
// And his suit of armor
obj = newObject()
obj.Type = Armor
obj.Which = RingMail
obj.Arm = aClass[RingMail] - 1
obj.Flags.Set(IsKnow)
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, Mace)
g.initWeapon(obj, WeaponMace)
obj.HPlus = 1
obj.DPlus = 1
obj.Flags.Set(IsKnow)
obj.Flags.Set(Known)
g.addPack(obj, true)
p.CurWeapon = obj
// Now a +1 bow
obj = newObject()
g.initWeapon(obj, Bow)
g.initWeapon(obj, WeaponBow)
obj.HPlus = 1
obj.Flags.Set(IsKnow)
obj.Flags.Set(Known)
g.addPack(obj, true)
// Now some arrows
obj = newObject()
g.initWeapon(obj, Arrow)
g.initWeapon(obj, WeaponArrow)
obj.Count = g.rnd(15) + 25
obj.Flags.Set(IsKnow)
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(rainbow))
for i := 0; i < MaxPotions; i++ {
used := make([]bool, len(g.data.rainbow))
for i := range NumPotionTypes {
var j int
for {
j = g.rnd(len(rainbow))
j = g.rnd(len(g.data.rainbow))
if !used[j] {
break
}
}
used[j] = true
g.Items.PotColors[i] = rainbow[j]
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 := 0; i < MaxScrolls; i++ {
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 := sylls[g.rnd(len(sylls))]
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(), " ")
}
}
@@ -87,47 +93,54 @@ func (g *RogueGame) initNames() {
// initStones initializes the ring stone setting scheme for this game
// (init.c init_stones).
func (g *RogueGame) initStones() {
used := make([]bool, len(stoneTable))
for i := 0; i < MaxRings; i++ {
used := make([]bool, len(g.data.stoneTable))
for i := range NumRingTypes {
var j int
for {
j = g.rnd(len(stoneTable))
j = g.rnd(len(g.data.stoneTable))
if !used[j] {
break
}
}
used[j] = true
g.Items.RingStones[i] = stoneTable[j].Name
g.Items.Rings[i].Worth += stoneTable[j].Value
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(woods))
metused := make([]bool, len(metals))
for i := 0; i < MaxSticks; i++ {
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(metals))
j := g.rnd(len(g.data.metals))
if !metused[j] {
g.Items.WandType[i] = "wand"
str = metals[j]
g.Items.WandType[i] = wandName
str = g.data.metals[j]
metused[j] = true
break
}
} else {
j := g.rnd(len(woods))
j := g.rnd(len(g.data.woods))
if !used[j] {
g.Items.WandType[i] = "staff"
str = woods[j]
g.Items.WandType[i] = staffName
str = g.data.woods[j]
used[j] = true
break
}
}
}
g.Items.WandMade[i] = str
}
}
@@ -143,28 +156,29 @@ func sumProbs(info []ObjInfo) {
// 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 = baseThings
g.Items.Potions = basePotInfo
g.Items.Scrolls = baseScrInfo
g.Items.Rings = baseRingInfo
g.Items.Sticks = baseWsInfo
g.Items.Weapons = baseWeapInfo
g.Items.Armors = baseArmInfo
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[:])
sumProbs(g.Items.Weapons[:MaxWeapons]) // C sums MAXWEAPONS, excluding the flame entry
sumProbs(g.Items.Weapons[:NumWeaponTypes]) // C sums MAXWEAPONS, excluding the flame entry
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(IsHalu) {
return rainbow[g.rnd(len(rainbow))]
if g.Player.On(Hallucinating) {
return g.data.rainbow[g.rnd(len(g.data.rainbow))]
}
return col
}

View File

@@ -10,9 +10,11 @@ import (
// maxMsg is io.c MAXMSG: how much message fits before --More--.
const maxMsg = NumCols - len("--More--") - 1
// MsgLine is the io.c message machinery: the static msgbuf/newpos pair plus
// the related globals (mpos, huh, and the message-behavior flags).
type MsgLine struct {
// 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
@@ -20,84 +22,137 @@ type MsgLine struct {
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 (g *RogueGame) msg(format string, a ...any) int {
// 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 == "" {
g.move(0, 0)
g.clrtoeol()
g.Msgs.Mpos = 0
m.scr.Std.Move(0, 0)
m.scr.Std.Clrtoeol()
m.Mpos = 0
return ^Escape
}
// otherwise add to the message and flush it out
g.doadd(format, a...)
return g.endmsg()
m.doaddf(format, a...)
return m.End()
}
// addmsg adds things to the current message (io.c addmsg).
func (g *RogueGame) addmsg(format string, a ...any) {
g.doadd(format, a...)
// Addf adds things to the current message (io.c addmsg).
func (m *MessageLine) Addf(format string, a ...any) {
m.doaddf(format, a...)
}
// endmsg displays a new msg, giving the player a chance to see the previous
// 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 (g *RogueGame) endmsg() int {
m := &g.Msgs
func (m *MessageLine) End() int {
if m.SaveMsg {
m.Huh = m.buf.String()
}
if m.Mpos != 0 {
g.look(false)
g.mvaddstr(0, m.Mpos, "--More--")
g.refresh()
if !m.MsgEsc {
g.waitFor(' ')
} else {
for {
ch := g.readchar()
if ch == ' ' {
break
}
if ch == Escape {
m.buf.Reset()
m.Mpos = 0
m.newpos = 0
return Escape
}
}
}
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] == ')') {
(len(out) <= 1 || out[1] != ')') {
out = string(toUpper(out[0])) + out[1:]
}
g.mvaddstr(0, 0, out)
g.clrtoeol()
m.scr.Std.MvAddStr(0, 0, out)
m.scr.Std.Clrtoeol()
m.Mpos = m.newpos
m.newpos = 0
m.buf.Reset()
g.refresh()
m.scr.Refresh()
return ^Escape
}
// doadd performs an add onto the message buffer (io.c doadd).
func (g *RogueGame) doadd(format string, a ...any) {
m := &g.Msgs
// 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 {
g.endmsg()
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 {
@@ -114,8 +169,10 @@ func (g *RogueGame) readchar() byte {
ch := g.scr.term.ReadChar()
if ch == 3 { // ^C
g.quit(0)
return 27
}
return ch
}
@@ -133,8 +190,6 @@ type statusCache struct {
init bool
}
var hungerStateName = [...]string{"", "Hungry", "Weak", "Faint"}
// status displays the important stats line, keeping the cursor where it was
// (io.c status).
func (g *RogueGame) status() {
@@ -142,21 +197,23 @@ func (g *RogueGame) status() {
p := &g.Player
// If nothing has changed since the last status, don't bother.
temp := p.Stats.Arm
temp := p.Stats.ArmorClass
if p.CurArmor != nil {
temp = p.CurArmor.Arm
temp = p.CurArmor.ArmorClass
}
if 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 {
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++
@@ -175,7 +232,7 @@ func (g *RogueGame) status() {
"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,
hungerStateName[p.HungryState])
g.data.hungerStateName[p.HungryState])
if g.StatMsg {
g.move(0, 0)
g.msg("%s", line)
@@ -183,10 +240,23 @@ func (g *RogueGame) status() {
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' {
@@ -197,7 +267,11 @@ func (g *RogueGame) waitFor(ch byte) {
}
}
}
for g.readchar() != ch {
for {
if g.readchar() == ch {
return
}
}
}
@@ -221,11 +295,13 @@ 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
}

View File

@@ -10,13 +10,13 @@ type Place struct {
// 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
NTraps int // number of traps on this level
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
@@ -47,9 +47,33 @@ 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

View File

@@ -4,159 +4,251 @@ package game
// 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
passcount := 0
rp := p.Room
if g.Oldpos != hero {
g.eraseLamp(g.Oldpos, g.Oldrp)
g.Oldpos = hero
g.Oldrp = rp
}
ey := hero.Y + 1
ex := hero.X + 1
sx := hero.X - 1
sy := hero.Y - 1
sumhero, diffhero := 0, 0
if g.DoorStop && !g.Firstmove && g.Running {
sumhero = hero.Y + hero.X
diffhero = hero.Y - hero.X
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)
pch := pp.Ch
pfl := pp.Flags
s.pch = pp.Ch
s.pfl = pp.Flags
for y := sy; y <= ey; y++ {
if y <= 0 || y >= NumLines-1 {
continue
}
for x := sx; x <= ex; x++ {
if x < 0 || x >= NumCols {
continue
}
if !p.On(IsBlind) {
if y == hero.Y && x == hero.X {
continue
}
}
g.lookAround(&s)
pp := g.Level.At(y, x)
ch := pp.Ch
if ch == ' ' { // nothing need be done with a ' '
continue
}
fp := &pp.Flags
if pch != Door && ch != Door {
if (pfl & FPass) != (*fp & FPass) {
continue
}
}
if (fp.Has(FPass) || ch == Door) && (pfl.Has(FPass) || pch == Door) {
if hero.X != x && hero.Y != y &&
!stepOk(g.Level.Char(y, hero.X)) && !stepOk(g.Level.Char(hero.Y, x)) {
continue
}
}
tp := pp.Monst
if tp == nil {
ch = g.tripCh(y, x, ch)
} else if p.On(SeeMonst) && tp.On(IsInvis) {
if g.DoorStop && !g.Firstmove {
g.Running = false
}
continue
} else {
if wakeup {
g.wakeMonster(y, x)
}
if g.seeMonst(tp) {
if p.On(IsHalu) {
ch = byte(g.rnd(26) + 'A')
} else {
ch = tp.Disguise
}
}
}
if p.On(IsBlind) && (y != hero.Y || x != hero.X) {
continue
}
g.move(y, x)
if p.Room.Flags.Has(IsDark) && !g.Options.SeeFloor && ch == Floor {
ch = ' '
}
if tp != nil || ch != g.inch() {
g.addch(ch)
}
if g.DoorStop && !g.Firstmove && g.Running {
switch g.RunCh {
case 'h':
if x == ex {
continue
}
case 'j':
if y == sy {
continue
}
case 'k':
if y == ey {
continue
}
case 'l':
if x == sx {
continue
}
case 'y':
if (y+x)-sumhero >= 1 {
continue
}
case 'u':
if (y-x)-diffhero >= 1 {
continue
}
case 'n':
if (y+x)-sumhero <= -1 {
continue
}
case 'b':
if (y-x)-diffhero <= -1 {
continue
}
}
switch ch {
case Door:
if x == hero.X || y == hero.Y {
g.Running = false
}
case Passage:
if x == hero.X || y == hero.Y {
passcount++
}
case Floor, '|', '-', ' ':
default:
g.Running = false
}
}
}
}
if g.DoorStop && !g.Firstmove && passcount > 1 {
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(IsHalu) && g.After {
if g.Player.On(Hallucinating) && g.After {
switch ch {
case Floor, ' ', Passage, '-', '|', Door, Trap:
default:
@@ -165,26 +257,30 @@ func (g *RogueGame) tripCh(y, x int, ch byte) byte {
}
}
}
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&(IsGone|IsDark) == IsDark &&
!g.Player.On(IsBlind)) {
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(' ')
}
@@ -195,56 +291,56 @@ func (g *RogueGame) eraseLamp(pos Coord, rp *Room) {
// 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&(IsGone|IsDark) == IsDark && !g.Player.On(IsBlind) {
if g.Player.Room.Flags&(Gone|Dark) == Dark && !g.Player.On(Blind) {
return g.Options.SeeFloor
}
return true
}
// findObj finds the unclaimed object at (y, x) (misc.c find_obj).
func (g *RogueGame) findObj(y, x int) *Object {
for _, obj := range g.Level.Objects {
if obj.Pos.Y == y && obj.Pos.X == x {
return obj
}
}
return nil
return true
}
// eat lets her try to eat something (misc.c eat).
func (g *RogueGame) eat() {
obj := g.getItem("eat", int(Food))
if obj == nil {
obj, ok := g.promptPackItem("eat", KindFood)
if !ok {
return
}
if obj.Type != Food {
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
}
if obj.Which == 1 {
switch {
case obj.Which == 1:
g.msg("my, that was a yummy %s", g.Fruit)
} else if g.rnd(100) > 70 {
case g.rnd(100) > 70:
p.Stats.Exp++
g.msg("%s, this food tastes awful", g.chooseStr("bummer", "yuk"))
g.checkLevel()
} else {
default:
g.msg("%s, that tasted good", g.chooseStr("oh, wow", "yum"))
}
g.leavePack(obj, false, false)
}
@@ -252,38 +348,46 @@ func (g *RogueGame) eat() {
// check_level).
func (g *RogueGame) checkLevel() {
p := &g.Player
var i int
for i = 0; eLevels[i] != 0; i++ {
if eLevels[i] > p.Stats.Exp {
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)
}
}
// chgStr modifies the player's strength, keeping track of the highest it
// has been (misc.c chg_str).
func (g *RogueGame) chgStr(amt int) {
// 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, RAddStr) {
addStr(&comp, -p.CurRing[Left].Arm)
if p.IsRing(Left, RingAddStrength) {
addStr(&comp, -p.CurRing[Left].Bonus)
}
if p.IsRing(Right, RAddStr) {
addStr(&comp, -p.CurRing[Right].Arm)
if p.IsRing(Right, RingAddStrength) {
addStr(&comp, -p.CurRing[Right].Bonus)
}
if comp > p.MaxStats.Str {
p.MaxStats.Str = comp
}
@@ -301,26 +405,31 @@ func addStr(sp *int, amt int) {
// addHaste adds a haste to the player (misc.c add_haste).
func (g *RogueGame) addHaste(potion bool) bool {
p := &g.Player
if p.On(IsHaste) {
if p.On(Hasted) {
g.NoCommand += g.rnd(8)
p.Flags.Clear(IsRun | IsHaste)
p.Flags.Clear(Awake | Hasted)
g.Extinguish(DNohaste)
g.msg("you faint from exhaustion")
return false
}
p.Flags.Set(IsHaste)
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.
// 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)
g.runTo(mp.Pos)
}
}
@@ -330,10 +439,12 @@ 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 ""
}
@@ -343,21 +454,25 @@ 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.addmsg("That's already ")
g.addmsgf("That's already ")
}
g.msg("in use")
return true
}
return false
}
// getDir sets up the direction coordinate for use in various "prefix"
// commands (misc.c get_dir).
func (g *RogueGame) getDir() bool {
// 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
@@ -367,53 +482,76 @@ func (g *RogueGame) getDir() bool {
prompt = "which direction? "
g.msg("%s", prompt)
}
for {
gotit := true
switch g.DirCh = g.readchar(); g.DirCh {
case 'h', 'H':
g.Delta = Coord{X: -1, Y: 0}
case 'j', 'J':
g.Delta = Coord{X: 0, Y: 1}
case 'k', 'K':
g.Delta = Coord{X: 0, Y: -1}
case 'l', 'L':
g.Delta = Coord{X: 1, Y: 0}
case 'y', 'Y':
g.Delta = Coord{X: -1, Y: -1}
case 'u', 'U':
g.Delta = Coord{X: 1, Y: -1}
case 'b', 'B':
g.Delta = Coord{X: -1, Y: 1}
case 'n', 'N':
g.Delta = Coord{X: 1, Y: 1}
case Escape:
g.DirCh = g.readchar()
if g.DirCh == Escape {
g.LastDir = 0
g.resetLast()
return false
default:
g.Msgs.Mpos = 0
g.msg("%s", prompt)
gotit = false
}
if gotit {
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(IsHuh) && g.rnd(5) == 0 {
for {
g.Delta.Y = g.rnd(3) - 1
g.Delta.X = g.rnd(3) - 1
if g.Delta.Y != 0 || g.Delta.X != 0 {
break
}
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
}
}
g.Msgs.Mpos = 0
return true
}
// 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).
@@ -422,6 +560,7 @@ func (g *RogueGame) callIt(info *ObjInfo) {
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 != "" {
@@ -431,29 +570,26 @@ func (g *RogueGame) callIt(info *ObjInfo) {
}
}
// thingList is misc.c rnd_thing()'s static table.
var thingList = []byte{
Potion, Scroll, Ring, Stick, Food, Weapon, Armor, Stairs, Gold, Amulet,
}
// 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(thingList))
i = g.rnd(len(g.data.thingList))
} else {
i = g.rnd(len(thingList) - 1)
i = g.rnd(len(g.data.thingList) - 1)
}
return thingList[i]
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(IsHalu) {
if g.Player.On(Hallucinating) {
return ts
}
return ns
}
@@ -463,8 +599,10 @@ func unctrl(ch byte) string {
if ch < ' ' {
return "^" + string(ch+'@')
}
if ch == 0x7f {
return "^?"
}
return string(ch)
}

View File

@@ -2,33 +2,24 @@ package game
// monsters.c — monster creation and saving throws.
// lvlMons and wandMons list monsters in rough order of vorpalness; zero
// entries in wandMons never wander (monsters.c).
var 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',
}
var 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,
}
// 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 := &lvlMons
mons := &g.data.lvlMons
if wander {
mons = &wandMons
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]
}
@@ -38,35 +29,37 @@ func (g *RogueGame) randMonster(wander bool) byte {
// 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 := g.Depth - AmuletLevel
if levAdd < 0 {
levAdd = 0
}
attachMon(&g.Level.Monsters, tp)
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)
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.Arm = mp.Stats.Arm - levAdd
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(IsHaste)
tp.Flags.Set(Hasted)
}
tp.Turn = true
tp.Pack = nil
if g.Player.IsWearing(RAggr) {
g.runto(cp)
if g.Player.IsWearing(RingAggravateMonsters) {
g.runTo(cp)
}
if typ == 'X' {
tp.Disguise = g.rndThing()
}
@@ -81,11 +74,13 @@ func expAdd(tp *Monster) int {
} else {
mod = tp.Stats.MaxHP / 6
}
if tp.Stats.Lvl > 9 {
mod *= 20
} else if tp.Stats.Lvl > 6 {
mod *= 4
}
return mod
}
@@ -93,73 +88,115 @@ func expAdd(tp *Monster) int {
// (monsters.c wanderer).
func (g *RogueGame) wanderer() {
tp := &Monster{}
var cp Coord
for {
cp, _ = g.findFloor(nil, 0, true)
if g.roomin(cp) != g.Player.Room {
cp, _ = g.findFloor(true)
if g.roomIn(cp) != g.Player.Room {
break
}
}
g.newMonster(tp, g.randMonster(true), cp)
if g.Player.On(SeeMonst) {
if g.Player.On(SenseMonsters) {
g.standout()
if !g.Player.On(IsHalu) {
if !g.Player.On(Hallucinating) {
g.addch(tp.Type)
} else {
g.addch(byte(g.rnd(26) + 'A'))
g.addch(g.randomMonsterLetter())
}
g.standend()
}
g.runto(tp.Pos)
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) *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")
}
ch := tp.Type
// Every time he sees a mean monster, it might start chasing him
if !tp.On(IsRun) && g.rnd(3) != 0 && tp.On(IsMean) && !tp.On(IsHeld) &&
!p.IsWearing(RStealth) && !p.On(IsLevit) {
if g.meanWakes(tp) {
tp.Dest = &p.Pos
tp.Flags.Set(IsRun)
tp.Flags.Set(Awake)
}
if ch == 'M' && !p.On(IsBlind) && !p.On(IsHalu) &&
!tp.On(IsFound) && !tp.On(IsCanc) && tp.On(IsRun) {
rp := p.Room
if (rp != nil && !rp.Flags.Has(IsDark)) ||
distance(y, x, p.Pos.Y, p.Pos.X) < LampDist {
tp.Flags.Set(IsFound)
if !g.save(VsMagic) {
if p.On(IsHuh) {
g.Lengthen(DUnconfuse, g.spread(HuhDuration))
} else {
g.Fuse(DUnconfuse, 0, g.spread(HuhDuration), After)
}
p.Flags.Set(IsHuh)
mname := g.setMname(tp)
g.addmsg("%s", mname)
if mname != "it" {
g.addmsg("'")
}
g.msg("s gaze has confused you")
}
}
if g.medusaCatches(tp) {
g.medusaGaze(tp, y, x)
}
// Let greedy ones guard gold
if tp.On(IsGreed) && !tp.On(IsRun) {
tp.Flags.Set(IsRun)
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
}
}
return tp
}
// 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
@@ -174,6 +211,7 @@ func (g *RogueGame) givePack(tp *Monster) {
// save_throw).
func (g *RogueGame) saveThrow(which int, st *Stats) bool {
need := 14 + which - st.Lvl/2
return g.roll(1, 20) >= need
}
@@ -182,12 +220,20 @@ func (g *RogueGame) saveThrow(which int, st *Stats) bool {
func (g *RogueGame) save(which int) bool {
p := &g.Player
if which == VsMagic {
if p.IsRing(Left, RProtect) {
which -= p.CurRing[Left].Arm
if p.IsRing(Left, RingProtection) {
which -= p.CurRing[Left].Bonus
}
if p.IsRing(Right, RProtect) {
which -= p.CurRing[Right].Arm
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
}

View File

@@ -2,158 +2,262 @@ package game
// move.c — hero movement commands.
// doRun starts the hero running (move.c do_run).
func (g *RogueGame) doRun(ch byte) {
// startRun starts the hero running (move.c do_run).
func (g *RogueGame) startRun(ch byte) {
g.Running = true
g.After = false
g.RunCh = ch
}
// doMove checks that a move is legal and handles the consequences —
// fighting, picking up, etc. (move.c do_move).
func (g *RogueGame) doMove(dy, dx int) {
// 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(IsHuh) && g.rnd(5) != 0 {
nh = g.rndmove(&p.Creature)
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}
}
over:
// Check if he tried to move off the screen or make an illegal diagonal
// move, and stop him if he did.
hitBound := nh.X < 0 || nh.X >= NumCols || nh.Y <= 0 || nh.Y >= NumLines-1
var ch byte
var fl PlaceFlags
if !hitBound {
if !g.diagOk(p.Pos, nh) {
g.After = false
g.Running = false
for {
ch, fl, stop := g.moveTarget(nh)
if stop {
return
}
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(IsLevit) {
ch = Trap
g.Level.SetChar(nh.Y, nh.X, Trap)
g.Level.FlagsAt(nh.Y, nh.X).Set(FReal)
}
} else if p.On(IsHeld) && ch != 'F' {
g.msg("you are being held")
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}
}
if hitBound {
ch = ' ' // fall into the wall case below
}
}
// 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 g.Options.PassGo && g.Running && p.Room.Flags.Has(IsGone) &&
!p.On(IsBlind) {
var b1, b2 bool
switch g.RunCh {
case 'h', 'l':
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 {
if b1 {
g.RunCh = 'k'
dy = -1
} else {
g.RunCh = 'j'
dy = 1
}
dx = 0
g.turnref()
nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx}
goto over
}
case 'j', 'k':
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 {
if b1 {
g.RunCh = 'h'
dx = -1
} else {
g.RunCh = 'l'
dx = 1
}
dy = 0
g.turnref()
nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx}
goto over
}
}
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(FPass) {
if g.Level.FlagsAt(p.Pos.Y, p.Pos.X).Has(FPassage) {
g.enterRoom(nh)
}
g.moveStuff(nh, fl)
case Trap:
tr := g.beTrapped(nh)
if tr == TDoor || tr == TTelep {
tr := g.springTrap(nh)
if tr == TrapDoor || tr == TrapTeleport {
return
}
g.moveStuff(nh, fl)
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)
g.moveStuff(nh, fl)
p.Room = g.roomIn(p.Pos)
case Floor:
if !fl.Has(FReal) {
g.beTrapped(p.Pos)
g.springTrap(p.Pos)
}
g.moveStuff(nh, fl)
default:
if ch == Stairs {
g.SeenStairs = true
}
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
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.moveStuff(nh, fl)
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)
}
}
// moveStuff is the move_stuff label in do_move: complete the step.
func (g *RogueGame) moveStuff(nh Coord, fl PlaceFlags) {
// 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(FPass) && g.Level.Char(g.Oldpos.Y, g.Oldpos.X) == Door {
if fl.Has(FPassage) && g.Level.Char(g.Oldpos.Y, g.Oldpos.X) == Door {
g.leaveRoom(nh)
}
p.Pos = nh
}
@@ -161,17 +265,21 @@ func (g *RogueGame) moveStuff(nh Coord, fl PlaceFlags) {
// (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|FPass) == (FReal|FPass)
return pp.Ch == Door || pp.Flags&(FReal|FPassage) == (FReal|FPassage)
}
// turnref decides whether to refresh at a passage turning (move.c turnref).
func (g *RogueGame) turnref() {
// 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)
}
}
@@ -179,9 +287,10 @@ func (g *RogueGame) turnref() {
// 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(IsGone) {
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)) {
@@ -191,102 +300,142 @@ func (g *RogueGame) doorOpen(rp *Room) {
}
}
// beTrapped makes him pay for stepping on a trap (move.c be_trapped).
func (g *RogueGame) beTrapped(tc Coord) int {
// 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(IsLevit) {
return TRust // anything that's not a door or teleport
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 := int(pp.Flags & FTMask)
tr := TrapKind(pp.Flags & FTrapMask)
pp.Flags.Set(FSeen)
switch tr {
case TDoor:
g.Depth++
g.NewLevel()
g.msg("you fell into a trap!")
case TBear:
g.NoMove += g.spread(3) // BEARTIME
g.msg("you are caught in a bear trap")
case TMyst:
switch g.rnd(11) {
case 0:
g.msg("you are suddenly in a parallel dimension")
case 1:
g.msg("the light in here suddenly seems %s", rainbow[g.rnd(len(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", rainbow[g.rnd(len(rainbow))])
case 5:
g.msg("a spike shoots past your ear!")
case 6:
g.msg("%s sparks dance across your armor", rainbow[g.rnd(len(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!", rainbow[g.rnd(len(rainbow))])
}
case TSleep:
g.NoCommand += g.spread(5) // SLEEPTIME
p.Flags.Clear(IsRun)
g.msg("a strange white mist envelops you and you fall asleep")
case TArrow:
if g.swing(p.Stats.Lvl-1, p.Stats.Arm, 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, Arrow)
arrow.Count = 1
arrow.Pos = p.Pos
g.fall(arrow, false)
g.msg("an arrow shoots past you")
}
case TTelep:
// 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)
case TDart:
if !g.swing(p.Stats.Lvl+1, p.Stats.Arm, 1) {
g.msg("a small dart whizzes by your ear and vanishes")
} else {
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(RSustStr) && !g.save(VsPoison) {
g.chgStr(-1)
}
g.msg("a small dart just hit you in the shoulder")
}
case TRust:
g.msg("a gush of water hits you on the head")
g.rustArmor(p.CurArmor)
if h := g.data.trapHandlers[tr]; h != nil {
h(g, tc)
}
g.flushType()
return tr
}
// rndmove moves in a random direction if the monster/person is confused
// (move.c rndmove).
func (g *RogueGame) rndmove(who *Creature) Coord {
// 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,
@@ -296,42 +445,50 @@ func (g *RogueGame) rndmove(who *Creature) Coord {
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.Which == SScare {
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.Type != Armor || arm.Which == Leather ||
arm.Arm >= 9 {
if arm == nil || arm.Kind != KindArmor || arm.ArmorKind() == ArmorLeather ||
arm.ArmorClass >= 9 {
return
}
if arm.Flags.Has(IsProt) || g.Player.IsWearing(RSustArm) {
if arm.Flags.Has(Protected) || g.Player.IsWearing(RingMaintainArmor) {
if !g.ToDeath {
g.msg("the rust vanishes instantly")
}
} else {
arm.Arm++
arm.ArmorClass++
if !g.Options.Terse {
g.msg("your armor appears to be weaker now. Oh my!")
} else {

View File

@@ -12,7 +12,8 @@ const (
// NewLevel digs and draws a new level (new_level.c new_level).
func (g *RogueGame) NewLevel() {
p := &g.Player
p.Flags.Clear(IsHeld) // unhold when you go down just in case
p.Flags.Clear(Held) // unhold when you go down just in case
if g.Depth > g.MaxDepth {
g.MaxDepth = g.Depth
}
@@ -20,64 +21,68 @@ func (g *RogueGame) NewLevel() {
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.doRooms() // Draw rooms
g.doPassages() // Draw passages
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.NTraps = g.rnd(g.Depth/4) + 1
if g.Level.NTraps > MaxTraps {
g.Level.NTraps = MaxTraps
}
for i := g.Level.NTraps; i > 0; i-- {
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(nil, 0, false)
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(NTraps))
*sp |= PlaceFlags(g.rnd(NumTrapTypes)) //nolint:gosec // G115: 0..7 fits
}
}
// Place the staircase down.
stairs, _ := g.findFloor(nil, 0, false)
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)
tp.Room = g.roomIn(tp.Pos)
}
hero, _ := g.findFloor(nil, 0, true)
hero, _ := g.findFloor(true)
p.Pos = hero
g.enterRoom(hero)
g.mvaddch(hero.Y, hero.X, PlayerCh)
if p.On(SeeMonst) {
if p.On(SenseMonsters) {
g.turnSee(false)
}
if p.On(IsHalu) {
if p.On(Hallucinating) {
g.visuals(0)
}
}
// rndRoom picks a room that is really there (new_level.c rnd_room).
func (g *RogueGame) rndRoom() int {
// 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(IsGone) {
if !g.Level.Rooms[rm].Flags.Has(Gone) {
return rm
}
}
@@ -93,67 +98,66 @@ func (g *RogueGame) putThings() {
}
// check for treasure rooms, and if so, put it in.
if g.rnd(treasRoomChance) == 0 {
g.treasRoom()
g.treasureRoom()
}
// Do MAXOBJ attempts to put things on a level
for i := 0; i < MaxObj; i++ {
for range MaxObj {
if g.rnd(100) < 36 {
// Pick a new object and link it in the list
obj := g.newThing()
attachObj(&g.Level.Objects, obj)
g.Level.AddObject(obj)
// Put it somewhere
obj.Pos, _ = g.findFloor(nil, 0, false)
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, obj.Type)
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()
attachObj(&g.Level.Objects, obj)
obj.Damage = "0x0"
obj.HurlDmg = "0x0"
obj.Arm = 11
obj.Type = Amulet
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(nil, 0, false)
obj.Pos, _ = g.findFloor(false)
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Amulet)
}
}
// treasRoom adds a treasure room (new_level.c treas_room).
func (g *RogueGame) treasRoom() {
rp := &g.Level.Rooms[g.rndRoom()]
spots := (rp.Max.Y-2)*(rp.Max.X-2) - minTreas
if spots > maxTreas-minTreas {
spots = maxTreas - minTreas
}
// 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
attachObj(&g.Level.Objects, tp)
g.Level.SetChar(mp.Y, mp.X, tp.Type)
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 := g.rnd(spots) + minTreas
if nm < numMonst+2 {
nm = numMonst + 2
}
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(IsMean) // no sloughers in THIS room
tp.Flags.Set(Mean) // no sloughers in THIS room
g.givePack(tp)
}
}
g.Depth--
}

View File

@@ -7,24 +7,30 @@ import (
func genLevel(t *testing.T, seed int32) *RogueGame {
t.Helper()
g := NewGame(Config{Seed: seed})
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 := 0; y < NumLines; y++ {
for x := 0; x < NumCols; x++ {
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()
}
@@ -32,75 +38,117 @@ func TestNewLevelInvariants(t *testing.T) {
for _, seed := range []int32{1, 12345, 2026, 99999} {
g := genLevel(t, seed)
// 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)
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)
}
// 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)
if mon.Room == nil {
t.Errorf("seed %d: monster %c has no room", seed, mon.Type)
}
}
}
// Some rooms exist and are drawn.
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)
}
// 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()
// Every monster is indexed on the map and placed in a room.
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)
}
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)
}
}
}
// Every level object sits on a cell displaying its type (items can
// share cells only with monsters standing on them).
for _, obj := range g.Level.Objects {
ch := g.Level.Char(obj.Pos.Y, obj.Pos.X)
if ch != obj.Type && g.Level.MonsterAt(obj.Pos.Y, obj.Pos.X) == nil {
t.Errorf("seed %d: object %q at (%d,%d) but map shows %q",
seed, obj.Type, 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()
// The player has her starting kit: food, armor, mace, bow, arrows.
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.Which != Mace {
t.Errorf("seed %d: not wielding the starting mace", seed)
}
if g.Player.CurArmor == nil || g.Player.CurArmor.Which != RingMail {
t.Errorf("seed %d: not wearing the starting ring mail", seed)
}
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) {
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")
@@ -111,10 +159,11 @@ func TestNewLevelDeterministic(t *testing.T) {
// mazes, dark rooms, traps, treasure rooms — as a crash/invariant sweep.
func TestDeeperLevels(t *testing.T) {
for _, seed := range []int32{7, 42, 1000, 31337} {
g := NewGame(Config{Seed: seed})
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)

View File

@@ -1,42 +1,183 @@
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 {
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 {
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 {
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 {
Type byte // what kind of object it is (Potion, Scroll, Weapon, ...)
Pos Coord // where it lives on the screen
Text string // what it says if you read it
Launch int // what you need to launch it (weapon index, -1 none)
PackCh byte // what character it is in the pack
Damage string // damage if used like sword
HurlDmg string // damage if thrown
Count int // count for plural objects
Which int // which object of a type it is
HPlus int // plusses to hit
DPlus int // plusses to damage
Arm int // armor protection — overloaded as in C (see Charges/GoldVal)
Flags ObjFlags
Group int // group number for this object
Label string // label for object
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: -1}
return &Object{Launch: noWeapon}
}
// Charges is the o_charges alias for Arm on wands and staffs.
func (o *Object) Charges() int { return o.Arm }
// PotionKind returns Which as a potion kind; valid only for KindPotion.
func (o *Object) PotionKind() PotionKind { return PotionKind(o.Which) }
// SetCharges stores a charge count in the overloaded Arm field.
func (o *Object) SetCharges(n int) { o.Arm = n }
// ScrollKind returns Which as a scroll kind; valid only for KindScroll.
func (o *Object) ScrollKind() ScrollKind { return ScrollKind(o.Which) }
// GoldVal is the o_goldval alias for Arm on gold piles.
func (o *Object) GoldVal() int { return o.Arm }
// RingKind returns Which as a ring kind; valid only for KindRing.
func (o *Object) RingKind() RingKind { return RingKind(o.Which) }
// SetGoldVal stores a gold value in the overloaded Arm field.
func (o *Object) SetGoldVal(n int) { o.Arm = n }
// 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) }
// attachObj is list.c attach(): push item onto the front of a list.
func attachObj(list *[]*Object, item *Object) {
@@ -48,6 +189,7 @@ func detachObj(list *[]*Object, item *Object) {
for i, o := range *list {
if o == item {
*list = append((*list)[:i], (*list)[i+1:]...)
return
}
}

View File

@@ -28,6 +28,7 @@ type optDesc struct {
// 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},
@@ -46,6 +47,7 @@ func (g *RogueGame) optList() []optDesc {
func (g *RogueGame) option() {
hw := g.scr.Hw
optlist := g.optList()
hw.Clear()
// Display current values of options
for i := range optlist {
@@ -56,9 +58,11 @@ func (g *RogueGame) option() {
}
// 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 {
@@ -70,6 +74,7 @@ func (g *RogueGame) option() {
i -= 2
} else { // trying to back up beyond the top
hw.Move(0, 0)
i--
}
}
@@ -84,18 +89,19 @@ func (g *RogueGame) option() {
// prOptname prints out the option name prompt (options.c pr_optname).
func (g *RogueGame) prOptname(op *optDesc) {
g.scr.Hw.Printw("%s (\"%s\"): ", op.prompt, op.name)
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(invTName[*op.intP])
hw.AddStr(g.data.invTName[*op.intP])
case optStr:
hw.AddStr(*op.strP)
}
@@ -105,6 +111,7 @@ func boolStr(b bool) string {
if b {
return "True"
}
return "False"
}
@@ -129,9 +136,11 @@ 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
@@ -145,13 +154,17 @@ func (g *RogueGame) getBool(bp *bool) int {
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
}
@@ -159,10 +172,12 @@ func (g *RogueGame) getBool(bp *bool) int {
// 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
@@ -172,6 +187,7 @@ func (g *RogueGame) getSf(bp *bool) int {
g.look(false)
}
}
return Norm
}
@@ -183,57 +199,36 @@ func (g *RogueGame) getStr(opt *string, win *Window) int {
oy, ox := win.GetYX()
g.scr.RefreshWin(win)
// loop reading in the string, and put it in a temporary buffer
var buf []byte
var c byte
var (
buf []byte
c byte
)
for {
c = g.readchar()
if c == '\n' || c == '\r' || c == Escape {
if endsInput(c) || (len(buf) == 0 && c == '-' && !onStd) {
break
}
if c == 8 || c == 0x7f { // erase character
if len(buf) > 0 {
buf = buf[:len(buf)-1]
win.Move(oy, ox+len(displayStr(buf)))
}
win.Clrtoeol()
g.scr.RefreshWin(win)
continue
}
if c == CTRL('U') { // kill character
buf = buf[:0]
win.Move(oy, ox)
win.Clrtoeol()
g.scr.RefreshWin(win)
continue
}
if len(buf) == 0 {
if c == '-' && !onStd {
break
}
if c == '~' {
buf = append(buf, g.Home...)
win.AddStr(g.Home)
win.Clrtoeol()
g.scr.RefreshWin(win)
continue
}
}
if len(buf) >= MaxInp || !(isPrint(c) || c == ' ') {
continue // C beeps here
}
buf = append(buf, c)
win.AddStr(unctrl(c))
win.Clrtoeol()
g.scr.RefreshWin(win)
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.MvPrintw(oy, ox, "%s\n", *opt)
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
@@ -244,12 +239,59 @@ func (g *RogueGame) getStr(opt *string, win *Window) int {
}
}
// 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()
}
@@ -257,10 +299,12 @@ func displayStr(buf []byte) string {
func (g *RogueGame) getInvT(ip *int) int {
win := g.scr.Hw
oy, ox := win.GetYX()
win.AddStr(invTName[*ip])
win.AddStr(g.data.invTName[*ip])
for {
win.Move(oy, ox)
g.scr.RefreshWin(win)
switch g.readchar() {
case 'o', 'O':
*ip = InvOver
@@ -276,11 +320,15 @@ func (g *RogueGame) getInvT(ip *int) int {
default:
win.Move(oy, ox+15)
win.AddStr("(O, S, or C)")
continue
}
break
}
win.MvPrintw(oy, ox, "%s\n", invTName[*ip])
win.MvPrintwf(oy, ox, "%s\n", g.data.invTName[*ip])
return Norm
}
@@ -289,87 +337,121 @@ func (g *RogueGame) getInvT(ip *int) int {
// "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++
}
name := str[:i]
rest := str[i:]
matched := false
for oi := range optlist {
op := &optlist[oi]
isBoolOpt := op.kind == optBool || op.kind == optSeeFloor
if strings.HasPrefix(op.name, name) && name != "" {
matched = true
if isBoolOpt {
*op.boolP = true
} else {
// 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]
rest = val[end:]
if op.kind == optInvT {
// check for type of inventory
w := word
if w != "" {
w = string(toUpper(w[0])) + w[1:]
}
for ti, tn := range invTName {
if strings.HasPrefix(tn, w) {
*op.intP = ti
break
}
}
} else {
*op.strP = prefix + strucpy(word)
}
}
break
} else if isBoolOpt && strings.HasPrefix(name, "no") &&
strings.HasPrefix(op.name, name[2:]) {
matched = true
*op.boolP = false
break
}
}
_ = matched
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 := 0; i < len(s); i++ {
for i := range len(s) {
if isPrint(s[i]) || s[i] == ' ' {
sb.WriteByte(s[i])
}
}
return sb.String()
}

View File

@@ -7,101 +7,26 @@ package game
func (g *RogueGame) addPack(obj *Object, silent bool) {
p := &g.Player
fromFloor := false
if obj == nil {
if obj = g.findObj(p.Pos.Y, p.Pos.X); 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 obj.Type == Scroll && obj.Which == SScare && obj.Flags.Has(ObjIsFound) {
detachObj(&g.Level.Objects, obj)
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh())
if p.Room.Flags.Has(IsGone) {
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")
if g.pickupScareScroll(obj) {
return
}
if len(p.Pack) == 0 {
p.Pack = append(p.Pack, obj)
obj.PackCh = g.packChar()
p.Inpack++
} else {
// Walk the pack looking for the insertion point, keeping items of
// one type together and merging stackable/grouped items — a direct
// translation of the C linked-list walk. lp is the index to insert
// after; -1 after a merge means no insertion.
lp := -1
merged := false
for i := 0; i < len(p.Pack); i++ {
if p.Pack[i].Type != obj.Type {
lp = i
continue
}
// found the group of our type: scan for matching subtype
for p.Pack[i].Type == obj.Type && p.Pack[i].Which != obj.Which {
lp = i
if i+1 >= len(p.Pack) {
break
}
i++
}
op := p.Pack[i]
if op.Type == obj.Type && op.Which == obj.Which {
if IsMult(op.Type) {
if !g.packRoom(fromFloor, obj) {
return
}
op.Count++
obj = op
lp = -1
merged = true
} else if obj.Group != 0 {
lp = i
for p.Pack[i].Type == obj.Type &&
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.Type == obj.Type && op.Which == obj.Which &&
op.Group == obj.Group {
op.Count += obj.Count
p.Inpack--
if !g.packRoom(fromFloor, obj) {
return
}
obj = op
lp = -1
merged = true
}
} else {
lp = i
}
}
break
}
if !merged && lp != -1 {
if !g.packRoom(fromFloor, obj) {
return
}
obj.PackCh = g.packChar()
p.Pack = append(p.Pack[:lp+1],
append([]*Object{obj}, p.Pack[lp+1:]...)...)
}
obj, ok := g.packInsert(obj, fromFloor)
if !ok {
return
}
obj.Flags.Set(ObjIsFound)
obj.Flags.Set(WasFound)
// If this was the object of something's desire, that monster will get
// mad and run at the hero.
@@ -111,158 +36,323 @@ func (g *RogueGame) addPack(obj *Object, silent bool) {
}
}
if obj.Type == Amulet {
if obj.Kind == KindAmulet {
g.HasAmulet = true
}
// Notify the user
if !silent {
if !g.Options.Terse {
g.addmsg("you now have ")
g.addmsgf("you now have ")
}
g.msg("%s (%c)", g.invName(obj, !g.Options.Terse), obj.PackCh)
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.addmsg("there's ")
g.addmsgf("there's ")
}
g.addmsg("no room")
g.addmsgf("no room")
if !g.Options.Terse {
g.addmsg(" in your pack")
g.addmsgf(" in your pack")
}
g.endmsg()
if fromFloor {
g.moveMsg(obj)
}
p.Inpack = MaxPack
return false
}
if fromFloor {
detachObj(&g.Level.Objects, obj)
g.Level.RemoveObject(obj)
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh())
if p.Room.Flags.Has(IsGone) {
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).
// 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 {
p := &g.Player
p.Inpack--
nobj := obj
if obj.Count > 1 && !all {
g.LastPick = obj
obj.Count--
if obj.Group != 0 {
p.Inpack++
}
if newobj {
copied := *obj
nobj = &copied
nobj.Count = 1
}
} else {
g.LastPick = nil
p.PackUsed[obj.PackCh-'a'] = false
detachObj(&p.Pack, obj)
}
return nobj
}
// packChar returns the next unused pack character (pack.c pack_char).
func (g *RogueGame) packChar() byte {
p := &g.Player
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
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, typ int) bool {
func (g *RogueGame) inventory(list []*Object, kind ObjectKind) bool {
g.NObjs = 0
for _, item := range list {
if typ != 0 && typ != int(item.Type) &&
!(typ == Callable && item.Type != Food && item.Type != Amulet) &&
!(typ == RorS && (item.Type == Ring || item.Type == Stick)) {
if !matchesFilter(kind, item) {
continue
}
g.NObjs++
g.Msgs.MsgEsc = true
line := string(item.PackCh) + ") " + g.invName(item, false)
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 g.Options.Terse {
if typ == 0 {
g.msg("empty handed")
} else {
g.msg("nothing appropriate")
}
if kind == KindNone {
g.msg("%s", g.chooseTerse("empty handed", "you are empty handed"))
} else {
if typ == 0 {
g.msg("you are empty handed")
} else {
g.msg("you don't have anything appropriate")
}
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(IsLevit) {
if p.On(Levitating) {
return
}
obj := g.findObj(p.Pos.Y, p.Pos.X)
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.GoldVal())
detachObj(&g.Level.Objects, obj)
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 {
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.addmsg("you ")
g.addmsgf("you ")
}
g.msg("moved onto %s", g.invName(obj, true))
g.msg("moved onto %s", g.inventoryName(obj, true))
}
// pickyInven allows the player to inventory a single item (pack.c
@@ -271,51 +361,55 @@ 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.invName(p.Pack[0], false))
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.invName(obj, false))
g.msg("%c) %s", mch, g.inventoryName(obj, false))
return
}
}
g.msg("'%s' not in pack", unctrl(mch))
}
// getItem picks something out of a pack for a purpose (pack.c get_item).
func (g *RogueGame) getItem(purpose string, typ int) *Object {
// 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
return nil, false
}
if g.Again {
if g.LastPick != nil {
return g.LastPick
}
g.msg("you ran out")
return nil
return g.repeatLastItem()
}
for {
if !g.Options.Terse {
g.addmsg("which object do you want to ")
}
g.addmsg("%s", purpose)
if g.Options.Terse {
g.addmsg(" what")
}
g.msg("? (* for list): ")
g.promptItemPurpose(purpose)
ch := g.readchar()
g.Msgs.Mpos = 0
// Give the poor player a chance to abort the command
@@ -323,40 +417,77 @@ func (g *RogueGame) getItem(purpose string, typ int) *Object {
g.resetLast()
g.After = false
g.msg("")
return nil
return nil, false
}
g.NObjs = 1 // normal case: person types one char
if ch == '*' {
g.Msgs.Mpos = 0
if !g.inventory(p.Pack, typ) {
if !g.inventory(p.Pack, kind) {
g.After = false
return nil
return nil, false
}
continue
}
for _, obj := range p.Pack {
if obj.PackCh == ch {
return obj
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(IsGone) {
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.addmsg("you found ")
g.addmsgf("you found ")
}
g.msg("%d gold pieces", value)
}
}
@@ -364,12 +495,14 @@ func (g *RogueGame) money(value int) {
// floorCh returns the appropriate floor character for her room
// (pack.c floor_ch).
func (g *RogueGame) floorCh() byte {
if g.Player.Room.Flags.Has(IsGone) {
if g.Player.Room.Flags.Has(Gone) {
return Passage
}
if g.showFloor() {
return Floor
}
return ' '
}
@@ -380,6 +513,7 @@ func (g *RogueGame) floorAt() byte {
if ch == Floor {
ch = g.floorCh()
}
return ch
}
@@ -396,5 +530,6 @@ func (g *RogueGame) chooseTerse(terse, verbose string) string {
if g.Options.Terse {
return terse
}
return verbose
}

View File

@@ -2,221 +2,269 @@ package game
// passages.c — draw the connecting passages.
// rdesConn is the hardcoded 3x3 room adjacency matrix from do_passages.
var 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},
}
// doPassages draws all the passages on a level (passages.c do_passages).
func (g *RogueGame) doPassages() {
var isconn [MaxRooms][MaxRooms]bool
var ingraph [MaxRooms]bool
// 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 {
for roomcount < MaxRooms {
// find a room to connect with
j := 0
r2 := -1
for i := 0; i < MaxRooms; i++ {
if rdesConn[r1][i] && !ingraph[i] {
if j++; g.rnd(j) == 0 {
r2 = i
}
}
}
if j == 0 {
// if no adjacent rooms are outside the graph, pick a new room
// to look from
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
}
}
} else {
// otherwise, connect new room to the graph, and draw a tunnel
// to it
ingraph[r2] = true
g.conn(r1, r2)
isconn[r1][r2] = true
isconn[r2][r1] = true
roomcount++
}
if roomcount >= MaxRooms {
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
j := 0
r2 := -1
for i := 0; i < MaxRooms; i++ {
if rdesConn[r1][i] && !isconn[r1][i] {
if j++; g.rnd(j) == 0 {
r2 = i
}
}
}
// if there is one, connect it and look for the next added passage
if j != 0 {
g.conn(r1, r2)
// 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.passnum()
g.numberPassages()
}
// conn draws a corridor from a room in a certain direction (passages.c
// conn).
func (g *RogueGame) conn(r1, r2 int) {
var rm int
var direc byte
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'
// 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
}
}
}
rpf := &g.Level.Rooms[rm]
// Set up the movement variables, in two cases: first drawing one down.
var rpt *Room
var del, turnDelta, spos, epos Coord
var distance, turnDistance int
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' {
rmt := rm + 3 // room # of dest
rpt = &g.Level.Rooms[rmt] // room pointer of dest
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(IsGone) { // if not gone pick door pos
for {
spos.X = rpf.Pos.X + g.rnd(rpf.Max.X-2) + 1
spos.Y = rpf.Pos.Y + rpf.Max.Y - 1
if !(rpf.Flags.Has(IsMaze) && !g.Level.FlagsAt(spos.Y, spos.X).Has(FPass)) {
break
}
}
}
if !rpt.Flags.Has(IsGone) {
for {
epos.X = rpt.Pos.X + g.rnd(rpt.Max.X-2) + 1
if !(rpt.Flags.Has(IsMaze) && !g.Level.FlagsAt(epos.Y, epos.X).Has(FPass)) {
break
}
}
}
distance = abs(spos.Y-epos.Y) - 1 // distance to move
turnDelta.Y = 0 // direction to turn
if spos.X < epos.X {
turnDelta.X = 1
} else {
turnDelta.X = -1
}
turnDistance = abs(spos.X - epos.X) // how far to turn
} else { // setup for moving right
rmt := rm + 1
rpt = &g.Level.Rooms[rmt]
del = Coord{X: 1, Y: 0}
spos = rpf.Pos
epos = rpt.Pos
if !rpf.Flags.Has(IsGone) {
for {
spos.X = rpf.Pos.X + rpf.Max.X - 1
spos.Y = rpf.Pos.Y + g.rnd(rpf.Max.Y-2) + 1
if !(rpf.Flags.Has(IsMaze) && !g.Level.FlagsAt(spos.Y, spos.X).Has(FPass)) {
break
}
}
}
if !rpt.Flags.Has(IsGone) {
for {
epos.Y = rpt.Pos.Y + g.rnd(rpt.Max.Y-2) + 1
if !(rpt.Flags.Has(IsMaze) && !g.Level.FlagsAt(epos.Y, epos.X).Has(FPass)) {
break
}
}
}
distance = abs(spos.X-epos.X) - 1
if spos.Y < epos.Y {
turnDelta.Y = 1
} else {
turnDelta.Y = -1
}
turnDelta.X = 0
turnDistance = abs(spos.Y - epos.Y)
plan = g.connPlanDown(rm)
} else {
plan = g.connPlanRight(rm)
}
turnSpot := g.rnd(distance-1) + 1 // where turn starts
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.
if !rpf.Flags.Has(IsGone) {
g.door(rpf, spos)
} else {
g.putpass(spos)
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'
}
if !rpt.Flags.Has(IsGone) {
g.door(rpt, epos)
} else {
g.putpass(epos)
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
}
// Get ready to move...
curr := spos
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 += del.X
curr.Y += del.Y
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.putpass(curr)
curr.X += turnDelta.X
curr.Y += turnDelta.Y
g.putPassage(curr)
curr.X += plan.turnDelta.X
curr.Y += plan.turnDelta.Y
}
}
// Continue digging along
g.putpass(curr)
g.putPassage(curr)
distance--
}
curr.X += del.X
curr.Y += del.Y
if curr != epos {
curr.X += plan.del.X
curr.Y += plan.del.Y
if curr != plan.epos {
g.msg("warning, connectivity problem on this level")
}
}
// putpass adds a passage character or secret passage here (passages.c
// putPassage adds a passage character or secret passage here (passages.c
// putpass).
func (g *RogueGame) putpass(cp Coord) {
func (g *RogueGame) putPassage(cp Coord) {
pp := g.Level.At(cp.Y, cp.X)
pp.Flags.Set(FPass)
pp.Flags.Set(FPassage)
if g.rnd(10)+1 < g.Depth && g.rnd(40) == 0 {
pp.Flags.Clear(FReal)
} else {
@@ -229,7 +277,7 @@ func (g *RogueGame) putpass(cp Coord) {
func (g *RogueGame) door(rm *Room, cp Coord) {
rm.Exits = append(rm.Exits, cp)
if rm.Flags.Has(IsMaze) {
if rm.Flags.Has(Maze) {
return
}
@@ -240,6 +288,7 @@ func (g *RogueGame) door(rm *Room, cp Coord) {
} else {
pp.Ch = '|'
}
pp.Flags.Clear(FReal)
} else {
pp.Ch = Door
@@ -250,79 +299,100 @@ func (g *RogueGame) door(rm *Room, cp Coord) {
// (passages.c add_pass).
func (g *RogueGame) addPass() {
for y := 1; y < NumLines-1; y++ {
for x := 0; x < NumCols; x++ {
pp := g.Level.At(y, x)
if pp.Flags.Has(FPass) || pp.Ch == Door ||
(!pp.Flags.Has(FReal) && (pp.Ch == '|' || pp.Ch == '-')) {
ch := pp.Ch
if pp.Flags.Has(FPass) {
ch = Passage
}
pp.Flags.Set(FSeen)
g.move(y, x)
if pp.Monst != nil {
pp.Monst.OldCh = pp.Ch
} else if pp.Flags.Has(FReal) {
g.addch(ch)
} else {
g.standout()
if pp.Flags.Has(FPass) {
g.addch(Passage)
} else {
g.addch(Door)
}
g.standend()
}
}
for x := range NumCols {
g.addPassSpot(g.Level.At(y, x), y, x)
}
}
}
// passnum assigns a number to each passageway (passages.c passnum).
func (g *RogueGame) passnum() {
// 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.numpass(rp.Exits[j].Y, rp.Exits[j].X)
g.numberPassage(rp.Exits[j].Y, rp.Exits[j].X)
}
}
}
// numpass numbers a passageway square and its brethren (passages.c
// numberPassage numbers a passageway square and its brethren (passages.c
// numpass).
func (g *RogueGame) numpass(y, x int) {
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(FPNum) {
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 ch := g.Level.Char(y, x); ch == Door ||
(!fp.Has(FReal) && (ch == '|' || ch == '-')) {
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(FPass) {
} else if !fp.Has(FPassage) {
return
}
*fp |= PlaceFlags(g.pnum)
*fp |= PlaceFlags(g.pnum) //nolint:gosec // G115: pnum < MaxPass=13
// recurse on the surrounding places
g.numpass(y+1, x)
g.numpass(y-1, x)
g.numpass(y, x+1)
g.numpass(y, x-1)
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.
@@ -330,5 +400,6 @@ func abs(n int) int {
if n < 0 {
return -n
}
return n
}

View File

@@ -13,185 +13,233 @@ type pact struct {
straight string
}
// pActions is potions.c p_actions[]. The P_SEEINVIS message is dynamic
// (it names the fruit) and is computed in doPot.
var pActions = [MaxPotions]pact{
PConfuse: {IsHuh, DUnconfuse, HuhDuration,
"what a tripy feeling!",
"wait, what's going on here. Huh? What? Who?"},
PLSD: {IsHalu, DComeDown, SeeDuration,
"Oh, wow! Everything seems so cosmic!",
"Oh, wow! Everything seems so cosmic!"},
PSeeInvis: {CanSee, DUnsee, SeeDuration, "", ""},
PBlind: {IsBlind, DSight, SeeDuration,
"oh, bummer! Everything is dark! Help!",
"a cloak of darkness falls around you"},
PLevit: {IsLevit, DLand, HealTime,
"oh, wow! You're floating in the air!",
"you start to float in the air"},
}
// quaff drinks a potion from the pack (potions.c quaff).
func (g *RogueGame) quaff() {
p := &g.Player
obj := g.getItem("quaff", int(Potion))
obj, ok := g.promptPackItem("quaff", KindPotion)
// Make certain that it is something that we want to drink
if obj == nil {
if !ok {
return
}
if obj.Type != Potion {
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(IsHalu)
trip := p.On(Hallucinating)
g.leavePack(obj, false, false)
switch obj.Which {
case PConfuse:
g.doPot(PConfuse, !trip)
case PPoison:
g.Items.Potions[PPoison].Know = true
if p.IsWearing(RSustStr) {
g.msg("you feel momentarily sick")
} else {
g.chgStr(-(g.rnd(3) + 1))
g.msg("you feel very sick now")
g.comeDown(0)
}
case PHealing:
g.Items.Potions[PHealing].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")
case PStrength:
g.Items.Potions[PStrength].Know = true
g.chgStr(1)
g.msg("you feel stronger, now. What bulging muscles!")
case PMFind:
p.Flags.Set(SeeMonst)
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"))
}
case PTFind:
// 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 tp.isMagic() {
show = true
g.scr.Hw.MvAddCh(tp.Pos.Y, tp.Pos.X, Magic)
g.Items.Potions[PTFind].Know = true
}
}
for _, mp := range g.Level.Monsters {
for _, tp := range mp.Pack {
if tp.isMagic() {
show = true
g.scr.Hw.MvAddCh(mp.Pos.Y, mp.Pos.X, Magic)
}
}
}
}
if show {
g.Items.Potions[PTFind].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"))
}
case PLSD:
if !trip {
if p.On(SeeMonst) {
g.turnSee(false)
}
g.StartDaemon(DVisuals, 0, Before)
g.SeenStairs = g.seenStairs()
}
g.doPot(PLSD, true)
case PSeeInvis:
show := p.On(CanSee)
g.doPot(PSeeInvis, false)
if !show {
g.invisOn()
}
g.sight(0)
case PRaise:
g.Items.Potions[PRaise].Know = true
g.msg("you suddenly feel much more skillful")
g.raiseLevel()
case PXHeal:
g.Items.Potions[PXHeal].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")
case PHaste:
g.Items.Potions[PHaste].Know = true
g.After = false
if g.addHaste(true) {
g.msg("you feel yourself moving much faster")
}
case PRestore:
if p.IsRing(Left, RAddStr) {
addStr(&p.Stats.Str, -p.CurRing[Left].Arm)
}
if p.IsRing(Right, RAddStr) {
addStr(&p.Stats.Str, -p.CurRing[Right].Arm)
}
if p.Stats.Str < p.MaxStats.Str {
p.Stats.Str = p.MaxStats.Str
}
if p.IsRing(Left, RAddStr) {
addStr(&p.Stats.Str, p.CurRing[Left].Arm)
}
if p.IsRing(Right, RAddStr) {
addStr(&p.Stats.Str, p.CurRing[Right].Arm)
}
g.msg("hey, this tastes great. It make you feel warm all over")
case PBlind:
g.doPot(PBlind, true)
case PLevit:
g.doPot(PLevit, true)
if h := g.data.quaffHandlers[obj.PotionKind()]; h != nil {
h(g, trip)
}
g.status()
// Throw the item away
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 = eLevels[g.Player.Stats.Lvl-1] + 1
g.Player.Stats.Exp = g.data.eLevels[g.Player.Stats.Lvl-1] + 1
g.checkLevel()
}
// doPot does a potion with standard setup: it uses a fuse and turns on a
// flag (potions.c do_pot).
func (g *RogueGame) doPot(typ int, knowit bool) {
pp := &pActions[typ]
if !g.Items.Potions[typ].Know {
g.Items.Potions[typ].Know = knowit
// 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)
@@ -200,33 +248,38 @@ func (g *RogueGame) doPot(typ int, knowit bool) {
} else {
g.Lengthen(pp.daemon, t)
}
high, straight := pp.high, pp.straight
if typ == PSeeInvis {
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 (o *Object) isMagic() bool {
switch o.Type {
case Armor:
return o.Flags.Has(IsProt) || o.Arm != aClass[o.Which]
case Weapon:
func (g *RogueGame) isMagic(o *Object) bool {
switch o.Kind {
case KindArmor:
return o.Flags.Has(Protected) || o.ArmorClass != g.data.aClass[o.Which]
case KindWeapon:
return o.HPlus != 0 || o.DPlus != 0
case Potion, Scroll, Stick, Ring, Amulet:
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(CanSee)
g.Player.Flags.Set(CanSeeInvisible)
for _, mp := range g.Level.Monsters {
if mp.On(IsInvis) && g.seeMonst(mp) && !g.Player.On(IsHalu) {
if mp.On(Invisible) && g.seeMonst(mp) && !g.Player.On(Hallucinating) {
g.mvaddch(mp.Pos.Y, mp.Pos.X, mp.Disguise)
}
}
@@ -236,55 +289,75 @@ func (g *RogueGame) invisOn() {
// 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 !canSee {
g.standout()
}
if !g.Player.On(IsHalu) {
g.addch(mp.Type)
} else {
g.addch(byte(g.rnd(26) + 'A'))
}
if !canSee {
g.standend()
addNew = true
}
} else if g.showSensed(mp, canSee) {
addNew = true
}
}
if turnOff {
g.Player.Flags.Clear(SeeMonst)
g.Player.Flags.Clear(SenseMonsters)
} else {
g.Player.Flags.Set(SeeMonst)
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(IsRun) { // visible and awake:
if g.seeMonst(tp) && tp.On(Awake) { // visible and awake:
return true // it must have moved there
}
if g.Player.On(SeeMonst) && tp.OldCh == Stairs {
if g.Player.On(SenseMonsters) && tp.OldCh == Stairs {
return true
}
}
return false
}

View File

@@ -7,17 +7,16 @@ import "fmt"
// ringOn puts a ring on a hand (rings.c ring_on).
func (g *RogueGame) ringOn() {
p := &g.Player
obj := g.getItem("put on", int(Ring))
obj, ok := g.promptPackItem("put on", KindRing)
// Make certain that it is something that we want to wear
if obj == nil {
if !ok {
return
}
if obj.Type != Ring {
if !g.Options.Terse {
g.msg("it would be difficult to wrap that around a finger")
} else {
g.msg("not a ring")
}
if obj.Kind != KindRing {
g.msg("%s", g.chooseTerse("not a ring",
"it would be difficult to wrap that around a finger"))
return
}
@@ -26,46 +25,56 @@ func (g *RogueGame) ringOn() {
return
}
var ring int
switch {
case p.CurRing[Left] == nil && p.CurRing[Right] == nil:
if ring = g.gethand(); ring < 0 {
return
}
case p.CurRing[Left] == nil:
ring = Left
case p.CurRing[Right] == nil:
ring = Right
default:
if !g.Options.Terse {
g.msg("you already have a ring on each hand")
} else {
g.msg("wearing two")
}
ring := g.pickRingHand()
if ring < 0 {
return
}
p.CurRing[ring] = obj
// Calculate the effect it has on the poor guy.
switch obj.Which {
case RAddStr:
g.chgStr(obj.Arm)
case RSeeInvis:
switch obj.RingKind() {
case RingAddStrength:
g.changeStrength(obj.Bonus)
case RingSeeInvisible:
g.invisOn()
case RAggr:
case RingAggravateMonsters:
g.aggravate()
}
if !g.Options.Terse {
g.addmsg("you are now wearing ")
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
}
g.msg("%s (%c)", g.invName(obj, true), obj.PackCh)
}
// 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 {
@@ -73,6 +82,7 @@ func (g *RogueGame) ringOff() {
} else {
g.msg("you aren't wearing any rings")
}
return
case p.CurRing[Left] == nil:
ring = Right
@@ -83,14 +93,18 @@ func (g *RogueGame) ringOff() {
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.invName(obj, true), obj.PackCh)
g.msg("was wearing %s(%c)", g.inventoryName(obj, true), obj.PackCh)
}
}
@@ -102,17 +116,22 @@ func (g *RogueGame) gethand() int {
} 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 {
@@ -121,25 +140,6 @@ func (g *RogueGame) gethand() int {
}
}
// 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).
var ringUses = [MaxRings]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
}
// ringEat reports how much food the ring on the given hand uses up
// (rings.c ring_eat).
func (g *RogueGame) ringEat(hand int) int {
@@ -147,7 +147,8 @@ func (g *RogueGame) ringEat(hand int) int {
if ring == nil {
return 0
}
eat := ringUses[ring.Which]
eat := g.data.ringUses[ring.RingKind()]
if eat < 0 {
if g.rnd(-eat) == 0 {
eat = 1
@@ -155,20 +156,25 @@ func (g *RogueGame) ringEat(hand int) int {
eat = 0
}
}
if ring.Which == RDigest {
if ring.RingKind() == RingSlowDigestion {
eat = -eat
}
return eat
}
// ringNum prints ring bonuses (rings.c ring_num).
func ringNum(g *RogueGame, obj *Object) string {
if !obj.Flags.Has(IsKnow) {
// 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 ""
}
switch obj.Which {
case RProtect, RAddStr, RAddDam, RAddHit:
return fmt.Sprintf(" [%s]", num(obj.Arm, 0, Ring))
switch obj.RingKind() {
case RingProtection, RingAddStrength, RingIncreaseDamage, RingDexterity:
return fmt.Sprintf(" [%s]", num(obj.Bonus, 0, Ring))
}
return ""
}

View File

@@ -2,76 +2,71 @@ package game
import (
"fmt"
"os"
"time"
)
// rip.c — the fun ends: death or a total win.
//
// The C functions here call exit(); the port panics with a gameEnd sentinel
// that Run recovers, so the terminal is restored by normal unwinding.
// 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.
// gameEnd is the sentinel carried by the panic that replaces my_exit().
type gameEnd struct{ status int }
// myExit leaves the process properly (main.c my_exit): it unwinds to Run.
func (g *RogueGame) myExit(st int) {
g.Playing = false
panic(gameEnd{status: st})
}
var ripArt = []string{
" __________",
" / \\",
" / REST \\",
" / IN \\",
" / PEACE \\",
" / \\",
" | |",
" | |",
" | killed by a |",
" | |",
" | 1980 |",
" *| * * * | *",
" ________)/\\\\_//(\\/(/\\)/\\//\\/|_)_______",
// 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.MvPrintw(NumLines-2, 0, "Killed by ")
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 ripArt {
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(0)
g.myExit()
}
// center returns the column to center the given string on the tombstone
@@ -85,6 +80,7 @@ func (g *RogueGame) totalWinner() {
p := &g.Player
g.clear()
g.standout()
banner := []string{
" ",
" @ @ @ @ @ @@@ @ @ ",
@@ -100,6 +96,7 @@ func (g *RogueGame) totalWinner() {
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")
@@ -110,127 +107,159 @@ func (g *RogueGame) totalWinner() {
g.clear()
g.mvaddstr(0, 0, " Worth Item")
g.move(1, 0)
oldpurse := p.Purse
line := 1
for _, obj := range p.Pack {
worth := 0
it := &g.Items
switch obj.Type {
case Food:
worth = 2 * obj.Count
case Weapon:
worth = it.Weapons[obj.Which].Worth
worth *= 3*(obj.HPlus+obj.DPlus) + obj.Count
obj.Flags.Set(IsKnow)
case Armor:
worth = it.Armors[obj.Which].Worth
worth += (9 - obj.Arm) * 100
worth += 10 * (aClass[obj.Which] - obj.Arm)
obj.Flags.Set(IsKnow)
case Scroll:
op := &it.Scrolls[obj.Which]
worth = op.Worth * obj.Count
if !op.Know {
worth /= 2
}
op.Know = true
case Potion:
op := &it.Potions[obj.Which]
worth = op.Worth * obj.Count
if !op.Know {
worth /= 2
}
op.Know = true
case Ring:
op := &it.Rings[obj.Which]
worth = op.Worth
if obj.Which == RAddStr || obj.Which == RAddDam ||
obj.Which == RProtect || obj.Which == RAddHit {
if obj.Arm > 0 {
worth += obj.Arm * 100
} else {
worth = 10
}
}
if !obj.Flags.Has(IsKnow) {
worth /= 2
}
obj.Flags.Set(IsKnow)
op.Know = true
case Stick:
op := &it.Sticks[obj.Which]
worth = op.Worth
worth += 20 * obj.Charges()
if !obj.Flags.Has(IsKnow) {
worth /= 2
}
obj.Flags.Set(IsKnow)
op.Know = true
case Amulet:
worth = 1000
}
if worth < 0 {
worth = 0
}
g.scr.Std.MvPrintw(line, 0, "%c) %5d %s", obj.PackCh, worth,
g.invName(obj, false))
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.MvPrintw(line, 0, " %5d Gold Pieces ", oldpurse)
g.scr.Std.MvPrintwf(line, 0, " %5d Gold Pieces ", oldpurse)
g.refresh()
g.score(p.Purse, 2, ' ')
g.myExit(0)
g.myExit()
}
// killnameTable is the rip.c nlist[]: special death causes.
var killnameTable = []helpEntry{
{'a', "arrow", true},
{'b', "bolt", true},
{'d', "dart", true},
{'h', "hypothermia", false},
{'s', "starvation", false},
// 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 {
it := &g.Items
worth := 0
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.aClass[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
var article bool
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 killnameTable {
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.
// random numbers to break patterns, then die a random death. It does not
// return — death exits the process.
func (g *RogueGame) DeathDemo() {
defer func() {
if r := recover(); r != nil {
if _, ok := r.(gameEnd); ok {
return
}
panic(r)
}
}()
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())
@@ -245,5 +274,6 @@ func (g *RogueGame) deathMonst() byte {
'Y', 'Z', 'a', 'b', 'h', 'd', 's',
' ', // generates the "Wally the Wonder Badger" message
}
return poss[g.rnd(len(poss))]
}

View File

@@ -11,21 +11,17 @@ type Rng struct {
Seed int32
}
// 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 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
}
@@ -35,9 +31,17 @@ func (r *Rng) Roll(number, sides int) int {
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) }

View File

@@ -46,6 +46,7 @@ func TestRndZeroDoesNotStep(t *testing.T) {
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)
}
@@ -58,6 +59,7 @@ func TestSpreadSmallValues(t *testing.T) {
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)
}

View File

@@ -17,10 +17,11 @@ type mazeState struct {
const goldGrp = 1
// doRooms creates rooms and corridors with a connectivity graph (rooms.c
// digRooms creates rooms and corridors with a connectivity graph (rooms.c
// do_rooms).
func (g *RogueGame) doRooms() {
func (g *RogueGame) digRooms() {
var bsze Coord // maximum room size
bsze.X = NumCols / 3
bsze.Y = NumLines / 3
// Clear things for a new level
@@ -32,92 +33,136 @@ func (g *RogueGame) doRooms() {
}
// Put the gone rooms, if any, on the level
leftOut := g.rnd(4)
for i := 0; i < leftOut; i++ {
g.Level.Rooms[g.rndRoom()].Flags.Set(IsGone)
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 {
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(IsGone) {
// Place a gone room. Make certain that there is a blank line
// for passage drawing.
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 {
break
}
}
continue
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
}
// set room type
if g.rnd(10) < g.Depth-1 {
rp.Flags.Set(IsDark) // dark room
if g.rnd(15) == 0 {
rp.Flags = IsMaze // 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
}
// Find a place and size for a random room
if rp.Flags.Has(IsMaze) {
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--
}
} else {
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 {
break
}
}
}
g.drawRoom(rp)
// Put the gold in
if g.rnd(2) == 0 && (!g.HasAmulet || g.Depth >= g.MaxDepth) {
gold := newObject()
rp.GoldVal = g.goldCalc()
gold.SetGoldVal(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 = IsMany
gold.Group = goldGrp
gold.Type = Gold
attachObj(&g.Level.Objects, gold)
}
// Put the monster in
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)
}
}
// 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(IsMaze) {
g.doMaze(rp)
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
@@ -145,8 +190,8 @@ func (g *RogueGame) horiz(rp *Room, starty int) {
}
}
// doMaze digs a maze (rooms.c do_maze).
func (g *RogueGame) doMaze(rp *Room) {
// 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] {
@@ -162,65 +207,84 @@ func (g *RogueGame) doMaze(rp *Room) {
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.putpass(pos)
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
del := [4]Coord{{X: 2, Y: 0}, {X: -2, Y: 0}, {X: 0, Y: 2}, {X: 0, Y: -2}}
for {
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(FPass) {
continue
}
if cnt++; g.rnd(cnt) == 0 {
nexty = newy
nextx = newx
}
}
if cnt == 0 {
nexty, nextx, ok := g.digPick(y, x)
if !ok {
return
}
g.accntMaze(y, x, nexty, nextx)
g.accntMaze(nexty, nextx, y, x)
var pos Coord
if nexty == y {
pos.Y = y + m.starty
if nextx-x < 0 {
pos.X = nextx + m.startx + 1
} else {
pos.X = nextx + m.startx - 1
}
} else {
pos.X = x + m.startx
if nexty-y < 0 {
pos.Y = nexty + m.starty + 1
} else {
pos.Y = nexty + m.starty - 1
}
}
g.putpass(pos)
pos.Y = nexty + m.starty
pos.X = nextx + m.startx
g.putpass(pos)
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)
}
}
// accntMaze accounts for maze exits (rooms.c accnt_maze).
func (g *RogueGame) accntMaze(y, x, ny, nx int) {
// 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 := 0; i < sp.nexits; i++ {
for i := range sp.nexits {
if sp.exits[i].Y == ny && sp.exits[i].X == nx {
return
}
@@ -233,18 +297,21 @@ func (g *RogueGame) accntMaze(y, x, ny, nx int) {
}
}
// rndPos picks a random spot in a room (rooms.c rnd_pos).
func (g *RogueGame) rndPos(rp *Room) Coord {
// 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 (rooms.c find_floor with rp == NULL).
func (g *RogueGame) findFloor(rp *Room, limit int, monst bool) (Coord, bool) {
return g.findFloorImpl(rp, limit, monst, rp == nil)
// 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
@@ -253,14 +320,22 @@ 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 = Floor
if rp.Flags.Has(IsMaze) {
compchar = Passage
}
compchar = floorChar(rp)
}
cnt := limit
for {
if limit != 0 {
@@ -268,14 +343,14 @@ func (g *RogueGame) findFloorImpl(rp *Room, limit int, monst, pickroom bool) (Co
return Coord{}, false
}
}
if pickroom {
rp = &g.Level.Rooms[g.rndRoom()]
compchar = Floor
if rp.Flags.Has(IsMaze) {
compchar = Passage
}
rp = &g.Level.Rooms[g.randomRoom()]
compchar = floorChar(rp)
}
cp := g.rndPos(rp)
cp := g.randomPos(rp)
pp := g.Level.At(cp.Y, cp.X)
if monst {
if pp.Monst == nil && stepOk(pp.Ch) {
@@ -291,87 +366,113 @@ func (g *RogueGame) findFloorImpl(rp *Room, limit int, monst, pickroom bool) (Co
// enter_room).
func (g *RogueGame) enterRoom(cp Coord) {
p := &g.Player
rp := g.roomin(cp)
rp := g.roomIn(cp)
p.Room = rp
g.doorOpen(rp)
if !rp.Flags.Has(IsDark) && !p.On(IsBlind) {
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++ {
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)
}
} else {
tp.OldCh = ch
if !g.seeMonst(tp) {
if p.On(SeeMonst) {
g.standout()
g.addch(tp.Disguise)
g.standend()
} else {
g.addch(ch)
}
} else {
g.addch(tp.Disguise)
}
}
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(IsMaze) {
if rp.Flags.Has(Maze) {
return
}
var floor byte
switch {
case rp.Flags.Has(IsGone):
case rp.Flags.Has(Gone):
floor = Passage
case !rp.Flags.Has(IsDark) || p.On(IsBlind):
case !rp.Flags.Has(Dark) || p.On(Blind):
floor = Floor
default:
floor = ' '
}
p.Room = &g.Level.Passages[*g.Level.FlagsAt(cp.Y, cp.X)&FPNum]
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.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) {
if p.On(SeeMonst) {
g.standout()
g.addch(ch)
g.standend()
break
}
pp := g.Level.At(y, x)
if pp.Ch == Door {
g.addch(Door)
} else {
g.addch(floor)
}
}
}
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)
}
}
}

View File

@@ -1,104 +1,204 @@
package game
import (
"path/filepath"
"strings"
"testing"
)
// TestRunScriptedSession drives a complete game through Run(): a few
// moves, a rest, an inventory, then Q-quit answered yes.
func TestRunScriptedSession(t *testing.T) {
tt := &testTerm{input: []byte("hjkl.i Qy")}
g := NewGame(Config{Seed: 99, Term: tt})
if err := g.Run(); err != nil {
t.Fatalf("Run: %v", err)
}
if g.Playing {
t.Error("still playing after quit")
}
// After quitting, the scoreboard is the last thing shown (in C it went
// to stdout after endwin; here it is drawn on the screen).
found := false
for y := 0; y < NumLines; y++ {
if strings.Contains(g.scr.Std.Line(y), "Top Ten") {
found = true
}
}
if !found {
t.Error("score list not on screen after quit")
// 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()
}
}
// TestRunManyTurns mashes movement keys for a while as a crash sweep of
// the whole turn loop (daemons, hunger, monsters, combat), ending with a
// quit. The input alternates directions so the hero bumps around rooms.
func TestRunManyTurns(t *testing.T) {
// Spaces between commands double as answers to any --More-- prompts;
// without them a single prompt would swallow the rest of the script
// (wait_for eats everything that isn't a space).
var script []byte
moves := []byte("h h j j k k l l y u b n s s . . ")
for range 200 {
script = append(script, moves...)
}
script = append(script, " Q y Qy"...)
tt := &testTerm{input: script}
g := NewGame(Config{Seed: 31337, Term: tt})
if err := g.Run(); err != nil {
t.Fatalf("Run: %v", err)
}
if g.Playing {
t.Error("session did not end")
}
}
// TestRunDownStairs walks the hero onto the stairs by teleporting there in
// wizard style, then descends and keeps playing.
// 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) {
tt := &testTerm{input: []byte(">..Qy")}
g := NewGame(Config{Seed: 7, Term: tt})
// '>' 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 Run from regenerating the level
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)
if err := g.Run(); err != nil {
t.Fatalf("Run: %v", err)
}
driveTurns(t, g, 1)
if g.Depth != 2 {
t.Errorf("depth = %d after descending, want 2", g.Depth)
}
}
// TestSaveCommandRoundTrip saves via the 'S' command (as a player would)
// and restores the game.
func TestSaveCommandRoundTrip(t *testing.T) {
// The C get_str caps input at MAXINP=50 characters, so the save path
// must be short: work from the temp directory.
t.Chdir(t.TempDir())
path := "cmd.save"
// 'S' with no default file name goes straight to the name prompt.
script := "S" + path + "\n"
tt := &testTerm{input: []byte(script)}
g := NewGame(Config{Seed: 55, Term: tt})
g.FileName = "" // force the name prompt
if err := g.Run(); err != nil {
t.Fatalf("Run: %v", err)
// 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) {
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
}
}
h, err := Restore(path, Config{Term: &testTerm{}})
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) {
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) {
// 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.Error("restored game does not match saved game")
}
// The restored game must be playable.
h.scr.term.(*testTerm).input = []byte("..Qy")
if err := h.Run(); err != nil {
t.Fatalf("restored Run: %v", err)
t.Errorf("restored game diverged: depth %d/%d purse %d/%d",
h.Depth, g.Depth, h.Player.Purse, g.Player.Purse)
}
return h
}

View File

@@ -2,6 +2,7 @@ package game
import (
"encoding/gob"
"errors"
"fmt"
"os"
)
@@ -11,6 +12,13 @@ import (
// 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 {
@@ -74,14 +82,14 @@ type SaveState struct {
HasAmulet bool
SeenStairs bool
Places []savedPlace
Rooms [MaxRooms]Room
Passages [MaxPass]Room
Objects []Object
Monsters []savedCreature
Dests []destRef // parallel to Monsters
Stairs Coord
NTraps int
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
@@ -124,16 +132,19 @@ 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
}
@@ -145,6 +156,7 @@ func (g *RogueGame) roomAt(i int) *Room {
case i >= 0:
return &g.Level.Rooms[i]
}
return nil
}
@@ -153,19 +165,61 @@ 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 {
p := &g.Player
st := &SaveState{
Version: Release,
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,
@@ -179,7 +233,7 @@ func (g *RogueGame) snapshot() *SaveState {
Rooms: g.Level.Rooms,
Passages: g.Level.Passages,
Stairs: g.Level.Stairs,
NTraps: g.Level.NTraps,
TrapCount: g.Level.TrapCount,
After: g.After,
Again: g.Again,
NoScoreF: g.NoScore,
@@ -212,25 +266,14 @@ func (g *RogueGame) snapshot() *SaveState {
AllScore: g.AllScore,
Screen: g.scr.Std.Contents(),
}
}
// 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,
}
}
// snapshotPlayer captures the player, equipment as pack indices (the
// player half of snapshot).
func (g *RogueGame) snapshotPlayer() savedPlayer {
p := &g.Player
// 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
}
// the player
st.Player = savedPlayer{
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,
@@ -246,55 +289,72 @@ func (g *RogueGame) snapshot() *SaveState {
MaxStats: p.MaxStats, VfHit: p.VfHit,
}
for _, o := range p.Pack {
st.Player.Body.Pack = append(st.Player.Body.Pack, *o)
sp.Body.Pack = append(sp.Body.Pack, *o)
}
// 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)
return sp
}
ref := destRef{}
switch {
case m.Dest == nil:
case m.Dest == &p.Pos:
ref = destRef{Kind: 1}
default:
for mi, om := range g.Level.Monsters {
if m.Dest == &om.Pos {
ref = destRef{Kind: 2, Idx: mi}
}
}
if ref.Kind == 0 {
for _, oo := range g.Level.Objects {
if m.Dest == &oo.Pos {
ref = destRef{Kind: 3, Idx: objAt[oo]}
}
}
}
if ref.Kind == 0 {
for ri := range g.Level.Rooms {
if m.Dest == &g.Level.Rooms[ri].Gold {
ref = destRef{Kind: 4, Idx: ri}
}
}
}
}
st.Dests = append(st.Dests, ref)
// 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}
}
return st
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) {
p := &g.Player
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
@@ -308,7 +368,13 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
g.Level.Rooms = st.Rooms
g.Level.Passages = st.Passages
g.Level.Stairs = st.Stairs
g.Level.NTraps = st.NTraps
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
@@ -339,22 +405,12 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
g.LastScore = st.LastScore
g.AllScore = st.AllScore
g.Playing = true
}
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)
}
// the player
// 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
@@ -364,15 +420,18 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
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)
@@ -388,11 +447,15 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
p.NoFood = sp.NoFood
p.MaxStats = sp.MaxStats
p.VfHit = sp.VfHit
}
// monsters, their map index, and their chase targets
// 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,
@@ -402,14 +465,21 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
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 = &p.Pos
m.Dest = &g.Player.Pos
case 2:
m.Dest = &g.Level.Monsters[ref.Idx].Pos
case 3:
@@ -418,136 +488,236 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
m.Dest = &g.Level.Rooms[ref.Idx].Gold
}
}
g.scr.Std.SetContents(st.Screen)
}
// saveGame implements the "save game" command (save.c save_game). The C
// goto over/gotfile flow becomes the useDefault flag.
// 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
over:
useDefault := false
if g.FileName != "" {
var c byte
for {
g.msg("save file (%s)? ", g.FileName)
c = g.readchar()
g.Msgs.Mpos = 0
if c == Escape {
g.msg("")
prompt:
for {
useDefault := false
if g.FileName != "" {
a := g.askDefaultSave()
if a == saveAbort {
return
}
if c == 'n' || c == 'N' || c == 'y' || c == 'Y' {
break
}
g.msg("please answer Y or N")
useDefault = a == saveYes
}
if c == 'y' || c == 'Y' {
g.addstr("Yes\n")
g.refresh()
useDefault = true
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
}
}
for {
var buf string
if useDefault {
buf = g.FileName
useDefault = false
} else {
g.Msgs.Mpos = 0
g.msg("file name: ")
if g.getStr(&buf, g.scr.Std) == Quit {
g.msg("")
return
}
g.Msgs.Mpos = 0
}
// test to see if the file exists
if _, err := os.Stat(buf); err == nil {
for {
g.msg("File exists. Do you wish to overwrite it?")
g.Msgs.Mpos = 0
c := g.readchar()
if c == Escape {
g.msg("")
return
}
if c == 'y' || c == 'Y' {
break
}
if c == 'n' || c == 'N' {
goto over
}
g.msg("Please answer Y or N")
}
g.msg("file name: %s", buf)
os.Remove(g.FileName)
}
g.FileName = buf
if err := g.saveFile(g.FileName); err != nil {
g.msg("%s", err.Error())
continue
}
break
}
g.myExit(0)
g.myExit()
}
// saveFile writes the saved game (save.c save_file).
// 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). A failed write means
// a corrupt save, so the file is removed before reporting the error.
func (g *RogueGame) saveFile(path string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o400)
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o400) //nolint:gosec,lll // G304: user-chosen save path
if err != nil {
return err
}
defer f.Close()
if err := gob.NewEncoder(f).Encode(g.snapshot()); err != nil {
os.Remove(path)
return err
encErr := gob.NewEncoder(f).Encode(g.snapshot())
closeErr := f.Close()
if encErr != nil || closeErr != nil {
_ = os.Remove(path) // don't leave a corrupt save behind
if encErr != nil {
return encErr
}
return closeErr
}
return os.Chmod(path, 0o400)
}
// AutoSave silently saves to the current file name; used on SIGHUP/SIGTERM
// (save.c auto_save).
// (save.c auto_save). Best effort by design: it runs on the way out of a
// dying process.
func (g *RogueGame) AutoSave() {
if g.FileName != "" {
os.Remove(g.FileName)
g.saveFile(g.FileName)
_ = os.Remove(g.FileName)
_ = g.saveFile(g.FileName)
}
}
// ErrSaveOutOfDate reports a save file from an incompatible version.
var ErrSaveOutOfDate = errors.New("sorry, saved game is out of date")
// 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, cfg Config) (*RogueGame, error) {
f, err := os.Open(path)
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 f.Close()
defer func() { _ = f.Close() }() // read-only handle
var st SaveState
if err := gob.NewDecoder(f).Decode(&st); err != nil {
return nil, fmt.Errorf("%s: corrupt or incompatible save file: %w", path, err)
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 != Release {
return nil, fmt.Errorf("sorry, saved game is out of date")
if st.Version != saveFormatVersion {
return nil, ErrSaveOutOfDate
}
g := &RogueGame{
data: newGameData(),
Rng: &Rng{},
Playing: true,
ScorePath: cfg.ScorePath,
ScorePath: params.ScorePath,
FileName: path,
rogueOpts: cfg.RogueOpts,
rogueOpts: params.RogueOpts,
restored: true,
}
g.scr = NewScreen(cfg.Term)
g.scr = NewScreen(params.Term)
g.Msgs.attach(g.scr, g.look, g.readchar)
g.applySnapshot(&st)
// defeat multiple restarting from the same place
if err := os.Remove(path); err != nil {
return nil, fmt.Errorf("cannot unlink file: %w", err)
rmErr := os.Remove(path)
if rmErr != nil {
return nil, fmt.Errorf("cannot unlink file: %w", rmErr)
}
return g, nil
}

View File

@@ -13,81 +13,121 @@ func TestSaveRestoreRoundTrip(t *testing.T) {
g.Player.Purse = 123
g.Player.FoodLeft = 777
g.HasAmulet = true
g.Items.Potions[PHealing].Know = true
g.Items.Scrolls[SMap].Guess = "map???"
g.Monsters['F'-'A'].Stats.Dmg = "3x1" // mutated bestiary must survive
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(IsRun)
g.Level.Monsters[0].Flags.Set(Awake)
g.Level.Monsters[0].Dest = &g.Player.Pos
}
path := filepath.Join(t.TempDir(), "rogue.save")
if err := g.saveFile(path); err != nil {
t.Fatalf("saveFile: %v", err)
saveErr := g.saveFile(path)
if saveErr != nil {
t.Fatalf("saveFile: %v", saveErr)
}
h, err := Restore(path, Config{Term: &testTerm{}})
h, err := Restore(path, Params{Term: &testTerm{}})
if err != nil {
t.Fatalf("Restore: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(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[PHealing].Know {
if !h.Items.Potions[PotionHealing].Know {
t.Error("potion identification lost")
}
if h.Items.Scrolls[SMap].Guess != "map???" {
if h.Items.Scrolls[ScrollMagicMapping].Guess != "map???" {
t.Error("scroll guess lost")
}
if h.Monsters['F'-'A'].Stats.Dmg != "3x1" {
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 {
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")
}
if len(g.Level.Monsters) == 0 {
return
}
// Equipment aliasing: the wielded mace must be the same *Object as the
// one in the pack.
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=%c which=%d", i, o, o.Type, o.Which)
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")
}
@@ -98,15 +138,24 @@ func TestRestoreRejectsWrongVersion(t *testing.T) {
path := filepath.Join(t.TempDir(), "rogue.save")
st := g.snapshot()
st.Version = "0.0.0"
f, err := os.Create(path)
f, err := os.Create(path) //nolint:gosec // G304: test temp path
if err != nil {
t.Fatal(err)
}
if err := gob.NewEncoder(f).Encode(st); err != nil {
t.Fatal(err)
encErr := gob.NewEncoder(f).Encode(st)
if encErr != nil {
t.Fatal(encErr)
}
f.Close()
if _, err := Restore(path, Config{}); err == nil {
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")
}
}

View File

@@ -25,29 +25,28 @@ type ScoreEnt struct {
Time int64
}
var scoreReasons = [4]string{
"killed",
"quit",
"A total winner",
"killed with Amulet",
}
// 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 f.Close()
defer func() { _ = f.Close() }() // read-only handle
var onDisk []ScoreEnt
if err := gob.NewDecoder(f).Decode(&onDisk); err != nil {
return topTen
decErr := gob.NewDecoder(f).Decode(&onDisk)
if decErr != nil {
return topTen // unreadable scoreboard reads as empty, as in C
}
copy(topTen, onDisk)
return topTen
}
@@ -57,29 +56,45 @@ func (g *RogueGame) wrScore(topTen []ScoreEnt) {
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, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
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 os.Remove(lock)
_ = lf.Close()
defer func() { _ = os.Remove(lock) }()
break
}
if fi, serr := os.Stat(lock); serr == nil &&
time.Since(fi.ModTime()) > 10*time.Second {
os.Remove(lock)
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, 0o644)
f, err := os.OpenFile(g.ScorePath,
os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return
}
defer f.Close()
gob.NewEncoder(f).Encode(topTen)
_ = 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) {
@@ -93,94 +108,11 @@ func (g *RogueGame) score(amount, flags int, monst byte) {
// Insert her in list if need be
ins := -1
if !g.NoScore && flags >= 0 {
uid := os.Getuid()
scp := len(topTen)
for i := range topTen {
if amount > topTen[i].Score {
scp = i
break
} else if !g.AllScore && flags != 2 &&
topTen[i].UID == uid && topTen[i].Flags != 2 {
// only one score per nowin uid
scp = len(topTen)
break
}
}
if scp < len(topTen) {
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(),
}
ins = scp
}
ins = g.scoreInsert(topTen, amount, flags, monst)
}
// Build the list display
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, scoreReasons[scp.Flags], scp.Level)
if scp.Flags == 0 || scp.Flags == 3 {
line += fmt.Sprintf(" by %s", g.killname(scp.Monster, true))
}
line += "."
if i == ins {
highlight = len(lines)
}
lines = append(lines, line)
}
if g.scr != nil && g.scr.term != nil {
g.clear()
for i, line := range lines {
if i == highlight {
g.standout()
}
g.mvaddstr(i, 0, line)
if i == highlight {
g.standend()
}
}
g.refresh()
} else {
for _, line := range lines {
fmt.Println(line)
}
}
lines, highlight := g.scoreLines(topTen, ins)
g.showScores(lines, highlight)
// Update the list file
if ins >= 0 {
@@ -188,6 +120,136 @@ func (g *RogueGame) score(amount, flags int, monst byte) {
}
}
// 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() {

View File

@@ -16,6 +16,9 @@ type Terminal interface {
// ReadChar blocks for the next key, translated to Rogue's input bytes
// (arrows become hjkl, control keys their C0 codes).
ReadChar() byte
// 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.
@@ -37,26 +40,28 @@ type Window struct {
func NewWindow(rows, cols int) *Window {
w := &Window{rows: rows, cols: cols, cells: make([]cell, rows*cols)}
w.Clear()
return w
}
func (w *Window) at(y, x int) *cell { return &w.cells[y*w.cols+x] }
// 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() (y, x int) { return w.cy, w.cx }
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
@@ -68,7 +73,7 @@ func (w *Window) AddCh(ch byte) {
// AddStr writes a string at the cursor (curses addstr).
func (w *Window) AddStr(s string) {
for i := 0; i < len(s); i++ {
for i := range len(s) {
w.AddCh(s[i])
}
}
@@ -85,15 +90,15 @@ func (w *Window) MvAddStr(y, x int, s string) {
w.AddStr(s)
}
// Printw writes formatted text at the cursor (curses printw).
func (w *Window) Printw(format string, a ...any) {
// Printwf writes formatted text at the cursor (curses printw).
func (w *Window) Printwf(format string, a ...any) {
w.AddStr(fmt.Sprintf(format, a...))
}
// MvPrintw moves then writes formatted text (curses mvprintw).
func (w *Window) MvPrintw(y, x int, format string, a ...any) {
// MvPrintwf moves then writes formatted text (curses mvprintw).
func (w *Window) MvPrintwf(y, x int, format string, a ...any) {
w.Move(y, x)
w.Printw(format, a...)
w.Printwf(format, a...)
}
// Inch returns the character under the cursor (curses inch, sans
@@ -102,12 +107,14 @@ 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()
}
@@ -120,6 +127,7 @@ func (w *Window) Clear() {
for i := range w.cells {
w.cells[i] = cell{ch: ' '}
}
w.cy, w.cx = 0, 0
}
@@ -128,6 +136,7 @@ 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: ' '}
}
@@ -138,13 +147,14 @@ func (w *Window) CopyFrom(src *Window) {
copy(w.cells, src.cells)
}
// Size reports the window dimensions.
func (w *Window) Size() (rows, cols int) { return w.rows, w.cols }
// 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) (ch byte, standout bool) {
func (w *Window) CellAt(y, x int) (byte, bool) {
c := w.at(y, x)
return c.ch, c.standout
}
@@ -155,6 +165,7 @@ func (w *Window) Contents() []byte {
for i, c := range w.cells {
out[i] = c.ch
}
return out
}
@@ -171,12 +182,16 @@ func (w *Window) SetContents(data []byte) {
// victory screens.
func (w *Window) Line(y int) string {
buf := make([]byte, w.cols)
for x := 0; x < w.cols; x++ {
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 {
@@ -201,6 +216,13 @@ func (s *Screen) Refresh() {
}
}
// Fini restores the terminal device, if there is one (curses endwin).
func (s *Screen) Fini() {
if s.term != nil {
s.term.Fini()
}
}
// RefreshWin pushes an arbitrary window to the device (curses wrefresh).
func (s *Screen) RefreshWin(w *Window) {
if s.term != nil {
@@ -217,7 +239,7 @@ 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.Printw(f, a...) }
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) }

View File

@@ -2,30 +2,23 @@ package game
// scrolls.c — read a scroll and let it happen.
// idType maps identify scrolls to the type they identify (scrolls.c
// static id_type).
var idType = [SIDRorS + 1]int{
SIDPotion: int(Potion),
SIDScroll: int(Scroll),
SIDWeapon: int(Weapon),
SIDArmor: int(Armor),
SIDRorS: RorS,
}
// readScroll reads a scroll from the pack and does the appropriate thing
// (scrolls.c read_scroll).
func (g *RogueGame) readScroll() {
p := &g.Player
obj := g.getItem("read", int(Scroll))
if obj == nil {
obj, ok := g.promptPackItem("read", KindScroll)
if !ok {
return
}
if obj.Type != Scroll {
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.
@@ -35,228 +28,346 @@ func (g *RogueGame) readScroll() {
// Get rid of the thing
g.leavePack(obj, false, false)
switch obj.Which {
case SConfuse:
// Scroll of monster confusion. Give him that power.
p.Flags.Set(CanHuh)
g.msg("your hands begin to glow %s", g.pickColor("red"))
case SArmor:
if p.CurArmor != nil {
p.CurArmor.Arm--
p.CurArmor.Flags.Clear(IsCursed)
g.msg("your armor glows %s for a moment", g.pickColor("silver"))
}
case SHold:
// Hold monster scroll. Stop all monsters within two spaces from
// chasing after the hero.
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(IsRun) {
mp.Flags.Clear(IsRun)
mp.Flags.Set(IsHeld)
held++
}
}
}
if held > 0 {
g.addmsg("the monster")
if held > 1 {
g.addmsg("s around you")
}
g.addmsg(" freeze")
if held == 1 {
g.addmsg("s")
}
g.endmsg()
g.Items.Scrolls[SHold].Know = true
} else {
g.msg("you feel a strange sense of loss")
}
case SSleep:
// Scroll which makes you fall asleep
g.Items.Scrolls[SSleep].Know = true
g.NoCommand += g.rnd(g.spread(5)) + 4 // SLEEPTIME
p.Flags.Clear(IsRun)
g.msg("you fall asleep")
case SCreate:
// Create a monster: first look in a circle around him, next try
// his room, otherwise give up
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.findObj(y, x); fo != nil && fo.Which == SScare {
continue
}
}
if i++; g.rnd(i) == 0 {
mp = Coord{Y: y, X: x}
}
}
}
}
if i == 0 {
g.msg("you hear a faint cry of anguish in the distance")
} else {
tp := &Monster{}
g.newMonster(tp, g.randMonster(false), mp)
}
case SIDPotion, SIDScroll, SIDWeapon, SIDArmor, SIDRorS:
// Identify, let him figure something out
g.Items.Scrolls[obj.Which].Know = true
g.msg("this scroll is an %s scroll", g.Items.Scrolls[obj.Which].Name)
g.whatis(true, idType[obj.Which])
case SMap:
// Scroll of magic mapping.
g.Items.Scrolls[SMap].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 := 0; x < NumCols; x++ {
pp := g.Level.At(y, x)
ch := pp.Ch
pass := false
switch ch {
case Door, Stairs:
case '-', '|':
if !pp.Flags.Has(FReal) {
ch = Door
pp.Ch = Door
pp.Flags.Set(FReal)
}
case ' ':
if pp.Flags.Has(FReal) {
// def: hidden things in walls stay hidden
if pp.Flags.Has(FPass) {
pass = true
} else {
ch = ' '
}
} else {
pp.Flags.Set(FReal)
pp.Ch = Passage
pass = true
}
case Passage:
pass = true
case Floor:
if pp.Flags.Has(FReal) {
ch = ' '
} else {
ch = Trap
pp.Ch = Trap
pp.Flags.Set(FSeen | FReal)
}
default:
if pp.Flags.Has(FPass) {
pass = true
} else {
ch = ' '
}
}
if pass {
if !pp.Flags.Has(FReal) {
pp.Ch = Passage
}
pp.Flags.Set(FSeen | FReal)
ch = Passage
}
if ch != ' ' {
if tp := pp.Monst; tp != nil {
tp.OldCh = ch
if !p.On(SeeMonst) {
g.mvaddch(y, x, ch)
}
} else {
g.mvaddch(y, x, ch)
}
}
}
}
case SFDet:
// Food detection
found := false
g.scr.Hw.Clear()
for _, fo := range g.Level.Objects {
if fo.Type == Food {
found = true
g.scr.Hw.MvAddCh(fo.Pos.Y, fo.Pos.X, Food)
}
}
if found {
g.Items.Scrolls[SFDet].Know = true
g.showWin("Your nose tingles and you smell food.--More--")
} else {
g.msg("your nose tingles")
}
case STelep:
// Scroll of teleportation: make him disappear and reappear
curRoom := p.Room
g.teleport()
if curRoom != p.Room {
g.Items.Scrolls[STelep].Know = true
}
case SEnch:
if p.CurWeapon == nil || p.CurWeapon.Type != Weapon {
g.msg("you feel a strange sense of loss")
} else {
p.CurWeapon.Flags.Clear(IsCursed)
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"))
}
case SScare:
// Reading it is a mistake and produces laughter at her poor boo
// boo.
g.msg("you hear maniacal laughter in the distance")
case SRemove:
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"))
case SAggr:
// 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")
case SProtect:
if p.CurArmor != nil {
p.CurArmor.Flags.Set(IsProt)
g.msg("your armor is covered by a shimmering %s shield",
g.pickColor("gold"))
} else {
g.msg("you feel a strange sense of loss")
}
if h := g.data.readHandlers[obj.ScrollKind()]; h != nil {
h(g, obj)
}
g.look(true) // put the result of the scroll on the screen
g.status()
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
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.idType[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(IsCursed)
obj.Flags.Clear(Cursed)
}
}

98
game/seedcompat_test.go Normal file
View File

@@ -0,0 +1,98 @@
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) {
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?)"
}

View File

@@ -4,170 +4,287 @@ 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() {
p := &g.Player
obj := g.getItem("zap with", int(Stick))
if obj == nil {
obj, ok := g.promptPackItem("zap with", KindWand)
if !ok {
return
}
if obj.Type != Stick {
if obj.Kind != KindWand {
g.After = false
g.msg("you can't zap with that!")
return
}
if obj.Charges() == 0 {
if obj.Charges == 0 {
g.msg("nothing happens")
return
}
switch obj.Which {
case WsLight:
// Reddy Kilowatt wand. Light up the room
g.Items.Sticks[WsLight].Know = true
if p.Room.Flags.Has(IsGone) {
g.msg("the corridor glows and then fades")
} else {
p.Room.Flags.Clear(IsDark)
// Light the room and put the player back up
g.enterRoom(p.Pos)
g.addmsg("the room is lit")
if !g.Options.Terse {
g.addmsg(" by a shimmering %s light", g.pickColor("blue"))
}
g.endmsg()
if h := g.data.zapHandlers[obj.WandKind()]; h != nil {
if !h(g, obj) {
return // the zap aborted; no charge is used
}
case WsDrain:
// 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 p.Stats.HP < 2 {
g.msg("you are too weak to use it")
return
}
g.drain()
case WsInvis, WsPolymorph, WsTelAway, WsTelTo, WsCancel:
y := p.Pos.Y
x := p.Pos.X
for stepOk(g.Level.VisibleChar(y, x)) {
y += g.Delta.Y
x += g.Delta.X
}
if tp := g.Level.MonsterAt(y, x); tp != nil {
monster := tp.Type
if monster == 'F' {
p.Flags.Clear(IsHeld)
}
switch obj.Which {
case WsInvis:
tp.Flags.Set(IsInvis)
if g.cansee(y, x) {
g.mvaddch(y, x, tp.OldCh)
}
case WsPolymorph:
pp := tp.Pack
detachMon(&g.Level.Monsters, 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 = byte(g.rnd(26) + 'A')
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[WsPolymorph].Know = true
}
case WsCancel:
tp.Flags.Set(IsCanc)
tp.Flags.Clear(IsInvis | CanHuh)
tp.Disguise = tp.Type
if g.seeMonst(tp) {
g.mvaddch(y, x, tp.Disguise)
}
case WsTelAway, WsTelTo:
var newPos Coord
if obj.Which == WsTelAway {
for {
newPos, _ = g.findFloor(nil, 0, 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(IsRun)
g.relocate(tp, newPos)
}
}
case WsMissile:
g.Items.Sticks[WsMissile].Know = true
bolt := newObject()
bolt.Type = '*'
bolt.HurlDmg = "1x4"
bolt.HPlus = 100
bolt.DPlus = 1
bolt.Flags = IsMissl
if p.CurWeapon != nil {
bolt.Launch = 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")
} else {
g.msg("the missle vanishes with a puff of smoke")
}
case WsHasteM, WsSlowM:
y := p.Pos.Y
x := p.Pos.X
for stepOk(g.Level.VisibleChar(y, x)) {
y += g.Delta.Y
x += g.Delta.X
}
if tp := g.Level.MonsterAt(y, x); tp != nil {
if obj.Which == WsHasteM {
if tp.On(IsSlow) {
tp.Flags.Clear(IsSlow)
} else {
tp.Flags.Set(IsHaste)
}
} else {
if tp.On(IsHaste) {
tp.Flags.Clear(IsHaste)
} else {
tp.Flags.Set(IsSlow)
}
tp.Turn = true
}
g.Delta.Y = y
g.Delta.X = x
g.runto(g.Delta)
}
case WsElect, WsFire, WsCold:
var name string
switch obj.Which {
case WsElect:
name = "bolt"
case WsFire:
name = "flame"
default:
name = "ice"
}
g.fireBolt(p.Pos, &g.Delta, name)
g.Items.Sticks[obj.Which].Know = true
case WsNop:
}
obj.SetCharges(obj.Charges() - 1)
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
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).
@@ -176,22 +293,26 @@ func (g *RogueGame) drain() {
// 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)&FPNum]
corp = &g.Level.Passages[*g.Level.FlagsAt(p.Pos.Y, p.Pos.X)&FPassNum]
}
inpass := p.Room.Flags.Has(IsGone)
inpass := p.Room.Flags.Has(Gone)
var drainee []*Monster
for _, mp := range g.Level.Monsters {
if 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)&FPNum] == p.Room) {
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
@@ -199,11 +320,23 @@ func (g *RogueGame) drain() {
if mp.Stats.HP -= cnt; mp.Stats.HP <= 0 {
g.killed(mp, g.seeMonst(mp))
} else {
g.runto(mp.Pos)
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) {
@@ -211,105 +344,51 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) {
fromHero := start == p.Pos
bolt := newObject()
bolt.Type = Weapon
bolt.Which = Flame
bolt.HurlDmg = "6x6"
bolt.Kind = KindWeapon
bolt.Which = int(WeaponFlame)
bolt.HurlDmg = dice("6x6")
bolt.HPlus = 100
bolt.DPlus = 0
g.Items.Weapons[Flame].Name = name
var dirch byte
switch dir.Y + dir.X {
case 0:
dirch = '/'
case 1, -1:
if dir.Y == 0 {
dirch = '-'
} else {
dirch = '|'
}
case 2, -2:
dirch = '\\'
}
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)
bounce := false
switch ch {
case Door:
// this code is necessary if the hero is on a door and he
// fires at the wall the door is in, it would otherwise loop
// infinitely
if p.Pos != pos {
bounce = true
}
case '|', '-', ' ':
bounce = true
}
if bounce {
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
tp.OldCh = g.Level.Char(pos.Y, pos.X)
if !g.saveThrow(VsMagic, &tp.Stats) {
bolt.Pos = pos
used = true
if tp.Type == 'D' && name == "flame" {
g.addmsg("the flame bounces")
if !g.Options.Terse {
g.addmsg(" off the dragon")
}
g.endmsg()
} else {
g.hitMonster(pos, bolt)
}
} else 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))
}
}
used = g.boltStrikesMonster(tp, bolt, pos, ch, name, fromHero)
} else if hitHero && pos == p.Pos {
hitHero = false
changed = !changed
if !g.save(VsMagic) {
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)
}
}
used = true
if g.Options.Terse {
g.msg("the %s hits", name)
} else {
g.msg("you are hit by the %s", name)
}
} else {
g.msg("the %s whizzes by you", name)
}
used = g.boltStrikesHero(start, name, fromHero)
}
g.mvaddch(pos.Y, pos.X, dirch)
g.refresh()
}
@@ -319,31 +398,132 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) {
}
}
// 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) {
if g.Items.WandType[cur.Which] == "staff" {
cur.Damage = "2x3"
if g.Items.WandType[cur.Which] == staffName {
cur.Damage = dice("2x3")
} else {
cur.Damage = "1x1"
cur.Damage = dice("1x1")
}
cur.HurlDmg = "1x1"
switch cur.Which {
case WsLight:
cur.SetCharges(g.rnd(10) + 10)
cur.HurlDmg = dice("1x1")
switch cur.WandKind() {
case WandLight:
cur.Charges = g.rnd(10) + 10
default:
cur.SetCharges(g.rnd(5) + 3)
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(IsKnow) {
if !obj.Flags.Has(Known) {
return ""
}
if g.Options.Terse {
return fmt.Sprintf(" [%d]", obj.Charges())
return fmt.Sprintf(" [%d]", obj.Charges)
}
return fmt.Sprintf(" [%d charges]", obj.Charges())
return fmt.Sprintf(" [%d charges]", obj.Charges)
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,16 +9,20 @@ func TestProbabilitiesSumTo100(t *testing.T) {
for _, oi := range info {
s += oi.Prob
}
return s
}
data := newGameData()
tables := map[string][]ObjInfo{
"things": baseThings[:],
"potions": basePotInfo[:],
"scrolls": baseScrInfo[:],
"rings": baseRingInfo[:],
"sticks": baseWsInfo[:],
"weapons": baseWeapInfo[:MaxWeapons], // excludes the flame entry
"armor": baseArmInfo[:],
"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 {
@@ -28,12 +32,14 @@ func TestProbabilitiesSumTo100(t *testing.T) {
}
func TestInitProbsCumulative(t *testing.T) {
g := NewGame(Config{Seed: 1})
last := g.Items.Potions[MaxPotions-1].Prob
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 := 1; i < MaxPotions; i++ {
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)
}
@@ -41,46 +47,77 @@ func TestInitProbsCumulative(t *testing.T) {
}
func TestNewGameRandomizesAppearances(t *testing.T) {
g := NewGame(Config{Seed: 12345})
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
}
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)
}
}
for i := range g.Items.WandType {
if g.Items.WandType[i] != "wand" && g.Items.WandType[i] != "staff" {
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)
}
}
g := New(Params{Seed: 12345})
checkPotionColors(t, g)
checkScrollNames(t, g)
checkWandMaterials(t, g)
// Determinism: same seed, same appearances.
h := NewGame(Config{Seed: 12345})
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) {
if monsterTable[0].Name != "aquator" || monsterTable[25].Name != "zombie" {
data := newGameData()
if data.monsterTable[0].Name != "aquator" || data.monsterTable[25].Name != "zombie" {
t.Error("monster table order broken")
}
if monsterTable['D'-'A'].Name != "dragon" {
if data.monsterTable['D'-'A'].Name != "dragon" {
t.Error("letter indexing broken")
}
}

View File

@@ -11,15 +11,20 @@ type testTerm struct {
func (t *testTerm) Render(*Window) {}
func (t *testTerm) Fini() {}
func (t *testTerm) ReadChar() byte {
if t.pos < len(t.input) {
c := t.input[t.pos]
t.pos++
return c
}
t.tick++
if t.tick%2 == 0 {
return '\n'
}
return ' '
}

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

View File

@@ -9,105 +9,163 @@ import (
// invName returns the name of something as it would appear in an inventory
// (things.c inv_name).
func (g *RogueGame) invName(obj *Object, drop bool) string {
func (g *RogueGame) inventoryName(obj *Object, drop bool) string {
var pb strings.Builder
which := obj.Which
it := &g.Items
switch obj.Type {
case Potion:
g.nameit(&pb, obj, "potion", it.PotColors[which], &it.Potions[which], nullstr)
case Ring:
g.nameit(&pb, obj, "ring", it.RingStones[which], &it.Rings[which], ringNum)
case Stick:
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 Scroll:
if obj.Count == 1 {
pb.WriteString("A scroll ")
} else {
fmt.Fprintf(&pb, "%d scrolls ", obj.Count)
}
op := &it.Scrolls[which]
if op.Know {
fmt.Fprintf(&pb, "of %s", op.Name)
} else if op.Guess != "" {
fmt.Fprintf(&pb, "called %s", op.Guess)
} else {
fmt.Fprintf(&pb, "titled '%s'", it.ScrNames[which])
}
case Food:
if 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)
}
} else {
if obj.Count == 1 {
pb.WriteString("Some food")
} else {
fmt.Fprintf(&pb, "%d rations of food", obj.Count)
}
}
case Weapon:
sp := it.Weapons[which].Name
if obj.Count > 1 {
fmt.Fprintf(&pb, "%d ", obj.Count)
} else {
fmt.Fprintf(&pb, "A%s ", vowelstr(sp))
}
if obj.Flags.Has(IsKnow) {
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)
}
case Armor:
sp := it.Armors[which].Name
if obj.Flags.Has(IsKnow) {
fmt.Fprintf(&pb, "%s %s [", num(aClass[which]-obj.Arm, 0, Armor), sp)
if !g.Options.Terse {
pb.WriteString("protection ")
}
fmt.Fprintf(&pb, "%d]", 10-obj.Arm)
} else {
pb.WriteString(sp)
}
if obj.Label != "" {
fmt.Fprintf(&pb, " called %s", obj.Label)
}
case Amulet:
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 Gold:
fmt.Fprintf(&pb, "%d Gold pieces", obj.GoldVal())
case KindGold:
fmt.Fprintf(&pb, "%d Gold pieces", obj.GoldValue)
}
out := pb.String()
if g.InvDescribe {
p := &g.Player
if obj == p.CurArmor {
out += " (being worn)"
}
if obj == p.CurWeapon {
out += " (weapon in hand)"
}
if obj == p.CurRing[Left] {
out += " (on left hand)"
} else if obj == p.CurRing[Right] {
out += " (on right hand)"
}
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)
}
if out != "" {
if drop && isUpper(out[0]) {
out = string(toLower(out[0])) + out[1:]
} else if !drop && isLower(out[0]) {
out = string(toUpper(out[0])) + out[1:]
}
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.aClass[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
}
@@ -115,29 +173,36 @@ func (g *RogueGame) invName(obj *Object, drop bool) string {
// 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 := g.getItem("drop", 0)
if obj == nil {
obj, ok := g.promptPackItem("drop", KindNone)
if !ok {
return
}
if !g.dropCheck(obj) {
return
}
obj = g.leavePack(obj, true, !IsMult(obj.Type))
obj = g.leavePack(obj, true, !obj.Kind.MergesInPack())
// Link it into the level object list
attachObj(&g.Level.Objects, obj)
g.Level.SetChar(p.Pos.Y, p.Pos.X, obj.Type)
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.Type == Amulet {
if obj.Kind == KindAmulet {
g.HasAmulet = false
}
g.msg("dropped %s", g.invName(obj, true))
g.msg("dropped %s", g.inventoryName(obj, true))
}
// dropCheck does special checks for dropping or unwielding|unwearing|
@@ -146,43 +211,60 @@ 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(IsCursed) {
if obj.Flags.Has(Cursed) {
g.msg("you can't. It appears to be cursed")
return false
}
if obj == p.CurWeapon {
switch obj {
case p.CurWeapon:
p.CurWeapon = nil
} else if obj == p.CurArmor {
case p.CurArmor:
g.wasteTime()
p.CurArmor = nil
} else {
hand := Right
if obj == p.CurRing[Left] {
hand = Left
}
p.CurRing[hand] = nil
switch obj.Which {
case RAddStr:
g.chgStr(-obj.Arm)
case RSeeInvis:
g.unsee(0)
g.Extinguish(DUnsee)
}
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
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 = "0x0"
cur.HurlDmg = "0x0"
cur.Arm = 11
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
@@ -193,59 +275,88 @@ func (g *RogueGame) newThing() *Object {
} else {
kind = pickOne(g, g.Items.Things[:])
}
switch kind {
case 0:
cur.Type = Potion
cur.Kind = KindPotion
cur.Which = pickOne(g, g.Items.Potions[:])
case 1:
cur.Type = Scroll
cur.Kind = KindScroll
cur.Which = pickOne(g, g.Items.Scrolls[:])
case 2:
cur.Type = Food
g.Player.NoFood = 0
if g.rnd(10) != 0 {
cur.Which = 0
} else {
cur.Which = 1
}
g.newFoodThing(cur)
case 3:
g.initWeapon(cur, pickOne(g, g.Items.Weapons[:MaxWeapons]))
if r := g.rnd(100); r < 10 {
cur.Flags.Set(IsCursed)
cur.HPlus -= g.rnd(3) + 1
} else if r < 15 {
cur.HPlus += g.rnd(3) + 1
}
g.newWeaponThing(cur)
case 4:
cur.Type = Armor
cur.Which = pickOne(g, g.Items.Armors[:])
cur.Arm = aClass[cur.Which]
if r := g.rnd(100); r < 20 {
cur.Flags.Set(IsCursed)
cur.Arm += g.rnd(3) + 1
} else if r < 28 {
cur.Arm -= g.rnd(3) + 1
}
g.newArmorThing(cur)
case 5:
cur.Type = Ring
cur.Which = pickOne(g, g.Items.Rings[:])
switch cur.Which {
case RAddStr, RProtect, RAddHit, RAddDam:
if cur.Arm = g.rnd(3); cur.Arm == 0 {
cur.Arm = -1
cur.Flags.Set(IsCursed)
}
case RAggr, RTeleport:
cur.Flags.Set(IsCursed)
}
g.newRingThing(cur)
case 6:
cur.Type = Stick
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[:])
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 {
@@ -255,6 +366,7 @@ func pickOne(g *RogueGame, info []ObjInfo) int {
return idx
}
}
return 0 // bad pick_one: C resets to the start of the table
}
@@ -272,20 +384,27 @@ type invPage struct {
// (things.c discovered).
func (g *RogueGame) discovered() {
var ch byte
for {
discList := false
if !g.Options.Terse {
g.addmsg("for ")
g.addmsgf("for ")
}
g.addmsg("what type")
g.addmsgf("what type")
if !g.Options.Terse {
g.addmsg(" of object do you want a list")
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
@@ -297,10 +416,12 @@ func (g *RogueGame) discovered() {
Potion, Scroll, Ring, Stick)
}
}
if discList {
break
}
}
if ch == '*' {
g.printDisc(Potion)
g.addLine("")
@@ -320,6 +441,7 @@ func (g *RogueGame) discovered() {
// (things.c print_disc).
func (g *RogueGame) printDisc(typ byte) {
var info []ObjInfo
switch typ {
case Scroll:
info = g.Items.Scrolls[:]
@@ -330,18 +452,23 @@ func (g *RogueGame) printDisc(typ byte) {
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.Type = typ
obj.Kind = objectKindForGlyph(typ)
obj.Which = order[i]
g.addLine("%s", g.invName(&obj, false))
g.addLine("%s", g.inventoryName(&obj, false))
numFound++
}
}
if numFound == 0 {
g.addLine("%s", g.nothing(typ))
}
@@ -353,6 +480,7 @@ 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]
@@ -366,8 +494,8 @@ const flushSentinel = "\x00"
func (g *RogueGame) addLine(format string, a ...any) int {
pg := &g.invPage
prompt := "--Press space to continue--"
isFlush := format == flushSentinel
var line string
if !isFlush {
line = fmt.Sprintf(format, a...)
@@ -375,64 +503,107 @@ func (g *RogueGame) addLine(format string, a ...any) int {
if pg.lineCnt == 0 {
g.scr.Hw.Clear()
if g.Options.InvType == InvSlow {
g.Msgs.Mpos = 0
}
}
if g.Options.InvType == InvSlow {
if !isFlush && line != "" {
if g.msg("%s", line) == Escape {
return Escape
}
}
pg.lineCnt++
} else {
if !pg.init {
pg.maxlen = len(prompt)
pg.init = true
}
if pg.lineCnt >= NumLines-1 || isFlush {
if g.Options.InvType == InvOver && isFlush && !pg.newpage {
// Overlay the accumulated list in a box at the top right
// of the screen, prompt, and restore what was beneath.
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()
} 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)
}
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
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) }
@@ -447,6 +618,7 @@ func (g *RogueGame) endLine() {
g.flushLine()
}
}
pg.lineCnt = 0
pg.newpage = false
}
@@ -459,20 +631,24 @@ func (g *RogueGame) nothing(typ byte) string {
} else {
out = "Haven't discovered anything"
}
if typ != '*' {
var tystr string
switch typ {
case Potion:
tystr = "potion"
tystr = potionName
case Scroll:
tystr = "scroll"
tystr = scrollName
case Ring:
tystr = "ring"
tystr = ringName
case Stick:
tystr = "stick"
}
out += fmt.Sprintf(" about any %ss", tystr)
}
return out
}
@@ -480,20 +656,22 @@ func (g *RogueGame) nothing(typ byte) string {
// (things.c nameit).
func (g *RogueGame) nameit(pb *strings.Builder, obj *Object, typ, which string,
op *ObjInfo, prfunc func(*RogueGame, *Object) string) {
if op.Know || op.Guess != "" {
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)
}
} else if obj.Count == 1 {
case obj.Count == 1:
fmt.Fprintf(pb, "A%s %s %s", vowelstr(which), which, typ)
} else {
default:
fmt.Fprintf(pb, "%d %s %ss", obj.Count, which, typ)
}
}
@@ -505,13 +683,17 @@ func nullstr(*RogueGame, *Object) string { return "" }
// pr_list).
func (g *RogueGame) prList() {
if !g.Options.Terse {
g.addmsg("for ")
g.addmsgf("for ")
}
g.addmsg("what type")
g.addmsgf("what type")
if !g.Options.Terse {
g.addmsg(" of object do you want a list")
g.addmsgf(" of object do you want a list")
}
g.msg("? ")
ch := g.readchar()
switch ch {
case Potion:
@@ -525,7 +707,7 @@ func (g *RogueGame) prList() {
case Armor:
g.prSpec(g.Items.Armors[:])
case Weapon:
g.prSpec(g.Items.Weapons[:MaxWeapons])
g.prSpec(g.Items.Weapons[:NumWeaponTypes])
}
}
@@ -533,14 +715,17 @@ func (g *RogueGame) prList() {
// (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()
}

View File

@@ -62,12 +62,6 @@ const (
Stick byte = '/'
)
// Pseudo-types for item-selection prompts (rogue.h)
const (
Callable = -1
RorS = -2
)
// Various constants (rogue.h)
const (
HealTime = 30
@@ -96,202 +90,253 @@ const (
VsMagic = 3
)
// Flags for rooms (rogue.h)
// RoomFlags are the room state bits (rogue.h room flags).
type RoomFlags int16
// Room state bits (rogue.h ISDARK/ISGONE/ISMAZE).
const (
IsDark RoomFlags = 1 << iota // room is dark
IsGone // room is gone (a corridor)
IsMaze // room is a maze
Dark RoomFlags = 1 << iota // room is dark
Gone // room is gone (a corridor)
Maze // room is a maze
)
func (f RoomFlags) Has(b RoomFlags) bool { return f&b != 0 }
func (f *RoomFlags) Set(b RoomFlags) { *f |= b }
func (f *RoomFlags) Clear(b RoomFlags) { *f &^= b }
// Has reports whether any of the given bits are set.
func (f *RoomFlags) Has(b RoomFlags) bool { return *f&b != 0 }
// Flags for objects (rogue.h)
// 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 (
IsCursed ObjFlags = 1 << iota // object is cursed
IsKnow // player knows details about the object
IsMissl // object is a missile type
IsMany // object comes in groups
ObjIsFound // object has been seen (ISFOUND shares the bit with creatures)
IsProt // armor is permanently protected
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): object has been seen (ISFOUND shares the bit with creatures)
Protected // ISPROT: armor is permanently protected
)
func (f ObjFlags) Has(b ObjFlags) bool { return f&b != 0 }
func (f *ObjFlags) Set(b ObjFlags) { *f |= b }
func (f *ObjFlags) Clear(b ObjFlags) { *f &^= b }
// Has reports whether any of the given bits are set.
func (f *ObjFlags) Has(b ObjFlags) bool { return *f&b != 0 }
// Flags for creatures (rogue.h). 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.
// 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 (
CanHuh CreatureFlags = 0o000001 // creature can confuse
CanSee CreatureFlags = 0o000002 // creature can see invisible creatures
IsBlind CreatureFlags = 0o000004 // creature is blind
IsCanc CreatureFlags = 0o000010 // creature has special qualities cancelled
IsLevit CreatureFlags = 0o000010 // hero is levitating
IsFound CreatureFlags = 0o000020 // creature has been seen
IsGreed CreatureFlags = 0o000040 // creature runs to protect gold
IsHaste CreatureFlags = 0o000100 // creature has been hastened
IsTarget CreatureFlags = 0o000200 // creature is the target of an 'f' command
IsHeld CreatureFlags = 0o000400 // creature has been held
IsHuh CreatureFlags = 0o001000 // creature is confused
IsInvis CreatureFlags = 0o002000 // creature is invisible
IsMean CreatureFlags = 0o004000 // creature can wake when player enters room
IsHalu CreatureFlags = 0o004000 // hero is on acid trip
IsRegen CreatureFlags = 0o010000 // creature can regenerate
IsRun CreatureFlags = 0o020000 // creature is running at the player
SeeMonst CreatureFlags = 0o040000 // hero can detect unseen monsters
IsFly CreatureFlags = 0o040000 // creature can fly
IsSlow CreatureFlags = 0o100000 // creature has been slowed
CanConfuse CreatureFlags = 0o000001 // CANHUH: creature can confuse
CanSeeInvisible CreatureFlags = 0o000002 // CANSEE: creature can see invisible creatures
Blind CreatureFlags = 0o000004 // ISBLIND: creature is blind
Cancelled CreatureFlags = 0o000010 // ISCANC: creature has 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: creature is the 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: creature can wake 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
)
func (f CreatureFlags) Has(b CreatureFlags) bool { return f&b != 0 }
func (f *CreatureFlags) Set(b CreatureFlags) { *f |= b }
func (f *CreatureFlags) Clear(b CreatureFlags) { *f &^= b }
// Has reports whether any of the given bits are set.
func (f *CreatureFlags) Has(b CreatureFlags) bool { return *f&b != 0 }
// Flags for the level map (rogue.h)
// 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 (
FPass PlaceFlags = 0x80 // 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
FPNum PlaceFlags = 0x0f // passage number mask
FTMask PlaceFlags = 0x07 // trap number mask
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
)
func (f PlaceFlags) Has(b PlaceFlags) bool { return f&b != 0 }
func (f *PlaceFlags) Set(b PlaceFlags) { *f |= b }
func (f *PlaceFlags) Clear(b PlaceFlags) { *f &^= b }
// Has reports whether any of the given bits are set.
func (f *PlaceFlags) Has(b PlaceFlags) bool { return *f&b != 0 }
// Trap types (rogue.h)
// 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 (
TDoor = 0
TArrow = 1
TSleep = 2
TBear = 3
TTelep = 4
TDart = 5
TRust = 6
TMyst = 7
NTraps = 8
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
)
// Potion types (rogue.h)
// PotionKind identifies a potion (rogue.h potion types).
type PotionKind int
// Potion kinds (rogue.h)
const (
PConfuse = iota
PLSD
PPoison
PStrength
PSeeInvis
PHealing
PMFind
PTFind
PRaise
PXHeal
PHaste
PRestore
PBlind
PLevit
MaxPotions
PotionConfusion PotionKind = iota
PotionLSD
PotionPoison
PotionGainStrength
PotionSeeInvisible
PotionHealing
PotionDetectMonsters
PotionDetectMagic
PotionRaiseLevel
PotionExtraHealing
PotionHaste
PotionRestoreStrength
PotionBlindness
PotionLevitation
NumPotionTypes
)
// Scroll types (rogue.h)
// ScrollKind identifies a scroll (rogue.h scroll types).
type ScrollKind int
// Scroll kinds (rogue.h)
const (
SConfuse = iota
SMap
SHold
SSleep
SArmor
SIDPotion
SIDScroll
SIDWeapon
SIDArmor
SIDRorS
SScare
SFDet
STelep
SEnch
SCreate
SRemove
SAggr
SProtect
MaxScrolls
ScrollMonsterConfusion ScrollKind = iota
ScrollMagicMapping
ScrollHoldMonster
ScrollSleep
ScrollEnchantArmor
ScrollIdentifyPotion
ScrollIdentifyScroll
ScrollIdentifyWeapon
ScrollIdentifyArmor
ScrollIdentifyRingOrStick
ScrollScareMonster
ScrollFoodDetection
ScrollTeleportation
ScrollEnchantWeapon
ScrollCreateMonster
ScrollRemoveCurse
ScrollAggravateMonsters
ScrollProtectArmor
NumScrollTypes
)
// Weapon types (rogue.h)
// WeaponKind identifies a weapon (rogue.h weapon types).
type WeaponKind int
// Weapon kinds (rogue.h)
const (
Mace = iota
Sword
Bow
Arrow
Dagger
TwoSword
Dart
Shiraken
Spear
Flame // fake entry for dragon breath (ick)
MaxWeapons = Flame
WeaponMace WeaponKind = iota
WeaponLongSword
WeaponBow
WeaponArrow
WeaponDagger
WeaponTwoHandedSword
WeaponDart
WeaponShuriken
WeaponSpear
WeaponFlame // fake entry for dragon breath (ick)
)
// Armor types (rogue.h)
// 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 (
Leather = iota
RingMail
StuddedLeather
ScaleMail
ChainMail
SplintMail
BandedMail
PlateMail
MaxArmors
ArmorLeather ArmorKind = iota
ArmorRingMail
ArmorStuddedLeather
ArmorScaleMail
ArmorChainMail
ArmorSplintMail
ArmorBandedMail
ArmorPlateMail
NumArmorTypes
)
// Ring types (rogue.h)
// RingKind identifies a ring (rogue.h ring types).
type RingKind int
// Ring kinds (rogue.h)
const (
RProtect = iota
RAddStr
RSustStr
RSearch
RSeeInvis
RNop
RAggr
RAddHit
RAddDam
RRegen
RDigest
RTeleport
RStealth
RSustArm
MaxRings
RingProtection RingKind = iota
RingAddStrength
RingSustainStrength
RingSearching
RingSeeInvisible
RingAdornment
RingAggravateMonsters
RingDexterity
RingIncreaseDamage
RingRegeneration
RingSlowDigestion
RingTeleportation
RingStealth
RingMaintainArmor
NumRingTypes
)
// Rod/Wand/Staff types (rogue.h)
// WandKind identifies a wand or staff (rogue.h rod/wand/staff types).
type WandKind int
// Wand kinds (rogue.h)
const (
WsLight = iota
WsInvis
WsElect
WsFire
WsCold
WsPolymorph
WsMissile
WsHasteM
WsSlowM
WsDrain
WsNop
WsTelAway
WsTelTo
WsCancel
MaxSticks
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
@@ -302,13 +347,13 @@ type Coord struct {
// 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
Arm int // armor class
HP int // hit points (s_hpt)
Dmg string // damage dice, e.g. "1x4/1x2"
MaxHP int
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).
@@ -349,13 +394,11 @@ type Stone struct {
// CTRL maps a letter to its control character, as the C CTRL() macro.
func CTRL(c byte) byte { return c & 0o37 }
// IsMult reports whether an object type stacks in the pack (rogue.h ISMULT).
func IsMult(typ byte) bool { return typ == Potion || typ == Scroll || typ == Food }
// 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
}
@@ -370,5 +413,6 @@ func sign(nm int) int {
case nm > 0:
return 1
}
return 0
}

View File

@@ -4,18 +4,20 @@ import "fmt"
// weapons.c — functions for dealing with problems brought about by weapons.
const noWeapon = -1
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 := g.getItem("throw", int(Weapon))
if obj == nil {
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
@@ -33,51 +35,64 @@ func (g *RogueGame) doMotion(obj *Object, ydelta, xdelta int) {
// Come fly with us ...
obj.Pos = p.Pos
for {
// Erase the old one
if obj.Pos != p.Pos && g.cansee(obj.Pos.Y, obj.Pos.X) && !g.Options.Terse {
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)
}
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 {
// 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.Type)
g.refresh()
}
continue
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()
}
break
}
}
// 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.Type
pp.Ch = obj.Kind.Glyph()
obj.Pos = fpos
if g.cansee(fpos.Y, fpos.X) {
if g.canSee(fpos.Y, fpos.X) {
if pp.Monst != nil {
pp.Monst.OldCh = obj.Type
pp.Monst.OldCh = obj.Kind.Glyph()
} else {
g.mvaddch(fpos.Y, fpos.X, obj.Type)
g.mvaddch(fpos.Y, fpos.X, obj.Kind.Glyph())
}
}
attachObj(&g.Level.Objects, obj)
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)
}
@@ -92,73 +107,78 @@ func (g *RogueGame) hitMonster(mp Coord, obj *Object) bool {
// 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 := g.getItem("wield", int(Weapon))
if obj == nil {
g.After = false
return
}
if obj.Type == Armor {
g.msg("you can't wield armor")
g.After = false
return
}
if g.isCurrent(obj) {
g.After = false
return
}
sp := g.invName(obj, true)
p.CurWeapon = obj
if !g.Options.Terse {
g.addmsg("you are now ")
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)
}
// initWeaps is the weapons.c init_dam[] table.
var initWeaps = [MaxWeapons]struct {
dam string // damage when wielded
hrl string // damage when thrown
launch int // launching weapon
// 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
}{
{"2x4", "1x3", noWeapon, 0}, // Mace
{"3x4", "1x2", noWeapon, 0}, // Long sword
{"1x1", "1x1", noWeapon, 0}, // Bow
{"1x1", "2x3", Bow, IsMany | IsMissl}, // Arrow
{"1x6", "1x4", noWeapon, IsMissl}, // Dagger
{"4x4", "1x2", noWeapon, 0}, // 2h sword
{"1x1", "1x3", noWeapon, IsMany | IsMissl}, // Dart
{"1x2", "2x4", noWeapon, IsMany | IsMissl}, // Shuriken
{"2x3", "1x6", noWeapon, IsMissl}, // Spear
}
// initWeapon sets up a new weapon (weapons.c init_weapon).
func (g *RogueGame) initWeapon(weap *Object, which int) {
iwp := &initWeaps[which]
weap.Type = Weapon
weap.Which = which
func (g *RogueGame) initWeapon(weap *Object, which WeaponKind) {
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
if which == Dagger {
switch {
case which == WeaponDagger:
weap.Count = g.rnd(4) + 2
weap.Group = g.Items.Group
g.Items.Group++
} else if weap.Flags.Has(IsMany) {
case weap.Flags.Has(Stackable):
weap.Count = g.rnd(8) + 8
weap.Group = g.Items.Group
g.Items.Group++
} else {
default:
weap.Count = 1
weap.Group = 0
}
@@ -170,6 +190,7 @@ func num(n1, n2 int, typ byte) string {
if typ == Weapon {
out += fmt.Sprintf(",%+d", n2)
}
return out
}
@@ -177,7 +198,9 @@ func num(n1, n2 int, typ byte) string {
// (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
@@ -187,6 +210,7 @@ func (g *RogueGame) fallpos(pos Coord) (Coord, bool) {
y < 0 || x < 0 {
continue
}
ch := g.Level.Char(y, x)
if ch == Floor || ch == Passage {
if cnt++; g.rnd(cnt) == 0 {
@@ -196,5 +220,6 @@ func (g *RogueGame) fallpos(pos Coord) (Coord, bool) {
}
}
}
return newpos, cnt != 0
}

View File

@@ -8,190 +8,217 @@ package game
// create_obj).
func (g *RogueGame) createObj() {
obj := newObject()
g.msg("type of item: ")
obj.Type = g.readchar()
obj.Kind = objectKindForGlyph(g.readchar())
g.Msgs.Mpos = 0
g.msg("which %c do you want? (0-f)", obj.Type)
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
switch {
case obj.Type == Weapon || obj.Type == Armor:
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.aClass[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) {
switch obj.RingKind() {
case RingProtection, RingAddStrength, RingDexterity, RingIncreaseDamage:
g.msg("blessing? (+,-,n)")
bless := g.readchar()
g.Msgs.Mpos = 0
if bless == '-' {
obj.Flags.Set(IsCursed)
}
if obj.Type == Weapon {
g.initWeapon(obj, obj.Which)
if bless == '-' {
obj.HPlus -= g.rnd(3) + 1
}
if bless == '+' {
obj.HPlus += g.rnd(3) + 1
}
obj.Flags.Set(Cursed)
obj.Bonus = -1
} else {
obj.Arm = aClass[obj.Which]
if bless == '-' {
obj.Arm += g.rnd(3) + 1
}
if bless == '+' {
obj.Arm -= g.rnd(3) + 1
}
}
case obj.Type == Ring:
switch obj.Which {
case RProtect, RAddStr, RAddHit, RAddDam:
g.msg("blessing? (+,-,n)")
bless := g.readchar()
g.Msgs.Mpos = 0
if bless == '-' {
obj.Flags.Set(IsCursed)
obj.Arm = -1
} else {
obj.Arm = g.rnd(2) + 1
}
case RAggr, RTeleport:
obj.Flags.Set(IsCursed)
}
case obj.Type == Stick:
g.fixStick(obj)
case obj.Type == Gold:
g.msg("how much?")
buf := ""
if g.getStr(&buf, g.scr.Std) == Norm {
obj.SetGoldVal(cAtoi(buf))
obj.Bonus = g.rnd(2) + 1
}
case RingAggravateMonsters, RingTeleportation:
obj.Flags.Set(Cursed)
}
g.addPack(obj, false)
}
// 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 := 0; x < NumCols; x++ {
real := g.Level.FlagsAt(y, x).Has(FReal)
if !real {
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 !real {
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, typ int) {
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
}
var obj *Object
for {
obj = g.getItem("identify", typ)
if !insist {
break
}
if g.NObjs == 0 {
return
}
if obj == nil {
g.msg("you must identify something")
} else if typ != 0 && int(obj.Type) != typ &&
!(typ == RorS && (obj.Type == Ring || obj.Type == Stick)) {
g.msg("you must identify a %s", typeName(typ))
} else {
break
}
}
if obj == nil {
obj, ok := g.whatisPick(insist, kind)
if !ok {
return
}
switch obj.Type {
case Scroll:
switch obj.Kind {
case KindScroll:
setKnow(obj, g.Items.Scrolls[:])
case Potion:
case KindPotion:
setKnow(obj, g.Items.Potions[:])
case Stick:
case KindWand:
setKnow(obj, g.Items.Sticks[:])
case Weapon, Armor:
obj.Flags.Set(IsKnow)
case Ring:
case KindWeapon, KindArmor:
obj.Flags.Set(Known)
case KindRing:
setKnow(obj, g.Items.Rings[:])
}
g.msg("%s", g.invName(obj, false))
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(IsKnow)
obj.Flags.Set(Known)
info[obj.Which].Guess = ""
}
// typeNameTable is the wizard.c type_name static tlist.
var typeNameTable = []struct {
ch int
desc string
}{
{int(Potion), "potion"},
{int(Scroll), "scroll"},
{int(Food), "food"},
{RorS, "ring, wand or staff"},
{int(Ring), "ring"},
{int(Stick), "wand or staff"},
{int(Weapon), "weapon"},
{int(Armor), "suit of armor"},
}
// typeName returns the name of an object type (wizard.c type_name).
func typeName(typ int) string {
for _, hp := range typeNameTable {
if typ == hp.ch {
return hp.desc
}
}
return ""
}
// 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(nil, 0, true)
if g.roomin(c) != p.Room {
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(IsHeld) {
p.Flags.Clear(IsHeld)
if p.On(Held) {
p.Flags.Clear(Held)
p.VfHit = 0
g.Monsters['F'-'A'].Stats.Dmg = "000x0"
g.Monsters['F'-'A'].Stats.Dmg = dice("000x0")
}
g.NoMove = 0
g.Count = 0
g.Running = false

5
go.mod
View File

@@ -1,10 +1,11 @@
module sneak.berlin/go/rogue
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/gdamore/tcell/v2 v2.13.10 // 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

View File

@@ -5,13 +5,15 @@
package term
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"github.com/gdamore/tcell/v2"
"sneak.berlin/go/rogue/game"
"git.eeqj.de/sneak/rgoue/game"
)
// Tcell renders game Windows on a tcell screen and turns key events into
@@ -21,6 +23,9 @@ type Tcell struct {
last *game.Window // last rendered window, for resize redraws
}
// ErrScreenTooSmall reports a terminal below the required 80x24.
var ErrScreenTooSmall = errors.New("screen too small")
// New initializes the terminal. The screen must be at least 80x24, as the
// C game required.
func New() (*Tcell, error) {
@@ -28,16 +33,22 @@ func New() (*Tcell, error) {
if err != nil {
return nil, err
}
if err := s.Init(); err != nil {
return nil, err
initErr := s.Init()
if initErr != nil {
return nil, initErr
}
w, h := s.Size()
if h < game.NumLines || w < game.NumCols {
s.Fini()
return nil, fmt.Errorf("sorry, the screen must be at least %dx%d",
game.NumLines, game.NumCols)
return nil, fmt.Errorf("sorry, %w: %dx%d required",
ErrScreenTooSmall, game.NumCols, game.NumLines)
}
s.HideCursor()
return &Tcell{screen: s}, nil
}
@@ -49,17 +60,21 @@ func (t *Tcell) Fini() {
// Render blits a game window to the terminal (curses refresh).
func (t *Tcell) Render(w *game.Window) {
t.last = w
rows, cols := w.Size()
for y := 0; y < rows; y++ {
for x := 0; x < cols; x++ {
for y := range rows {
for x := range cols {
ch, standout := w.CellAt(y, x)
style := tcell.StyleDefault
if standout {
style = style.Reverse(true)
}
t.screen.SetContent(x, y, rune(ch), nil, style)
}
}
t.screen.Show()
}
@@ -74,62 +89,114 @@ func (t *Tcell) ReadChar() byte {
t.Render(t.last)
}
case *tcell.EventKey:
switch ev.Key() {
case tcell.KeyUp:
return 'k'
case tcell.KeyDown:
return 'j'
case tcell.KeyLeft:
return 'h'
case tcell.KeyRight:
return 'l'
case tcell.KeyHome:
return 'y'
case tcell.KeyPgUp:
return 'u'
case tcell.KeyEnd:
return 'b'
case tcell.KeyPgDn:
return 'n'
case tcell.KeyEnter:
return '\n'
case tcell.KeyEscape:
return game.Escape
case tcell.KeyBackspace, tcell.KeyBackspace2:
return 8
case tcell.KeyDelete:
return 0x7f
case tcell.KeyTab:
return '\t'
case tcell.KeyCtrlC:
return 3
default:
if ev.Key() >= tcell.KeyCtrlA && ev.Key() <= tcell.KeyCtrlZ {
return byte(ev.Key())
}
if r := ev.Rune(); r > 0 && r < 0x80 {
return byte(r)
}
if b, ok := translateKey(ev); ok {
return b
}
}
}
}
// translateKey converts a key event to a game input byte; ok is false
// for keys the C game does not understand.
func translateKey(ev *tcell.EventKey) (byte, bool) {
if b, ok := namedKey(ev.Key()); ok {
return b, true
}
if ev.Key() >= tcell.KeyCtrlA && ev.Key() <= tcell.KeyCtrlZ {
return byte(ev.Key()), true //nolint:gosec // G115: 1..26 fits
}
if r := ev.Rune(); r > 0 && r < 0x80 {
return byte(r), true
}
return 0, false
}
// namedKey translates tcell's navigation and editing keys to the single
// bytes the C game reads (arrows become hjkl, etc.); ok is false for
// keys handled elsewhere.
func namedKey(k tcell.Key) (byte, bool) {
if b, ok := motionKey(k); ok {
return b, true
}
return editingKey(k)
}
// motionKey translates the arrow and paging keys to Rogue's movement
// letters (tcell.go ReadChar).
func motionKey(k tcell.Key) (byte, bool) {
switch k {
case tcell.KeyUp:
return 'k', true
case tcell.KeyDown:
return 'j', true
case tcell.KeyLeft:
return 'h', true
case tcell.KeyRight:
return 'l', true
case tcell.KeyHome:
return 'y', true
case tcell.KeyPgUp:
return 'u', true
case tcell.KeyEnd:
return 'b', true
case tcell.KeyPgDn:
return 'n', true
}
return 0, false
}
// editingKey translates the editing and control keys to their C0 codes
// (tcell.go ReadChar).
func editingKey(k tcell.Key) (byte, bool) {
switch k {
case tcell.KeyEnter:
return '\n', true
case tcell.KeyEscape:
return game.Escape, true
case tcell.KeyBackspace, tcell.KeyBackspace2:
return 8, true
case tcell.KeyDelete:
return 0x7f, true
case tcell.KeyTab:
return '\t', true
case tcell.KeyCtrlC:
return 3, true
}
return 0, false
}
// ShellEscape suspends the screen and runs the user's shell (main.c
// shell + md_shellescape).
func (t *Tcell) ShellEscape() {
if err := t.screen.Suspend(); err != nil {
err := t.screen.Suspend()
if err != nil {
return
}
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
fmt.Println("[Entering shell; exit to return to the game]")
cmd := exec.Command(shell)
_, _ = fmt.Fprintln(os.Stdout,
"[Entering shell; exit to return to the game]")
// The shell session has no deadline by design; Background context.
cmd := exec.CommandContext(context.Background(), //nolint:gosec // G204: the user's own $SHELL
shell)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
t.screen.Resume()
_ = cmd.Run() // best effort: the shell is the user's business
resumeErr := t.screen.Resume()
if resumeErr != nil {
panic(resumeErr) // terminal resume failure is unrecoverable
}
}