A whole run as one value, and one turn as one function over it.
command/2 is the only way the world changes. It takes the state and what the player asked
for and returns the state after everything that follows. Nothing in this module touches the
terminal, spawns a process or reads a file, so a run can be played out by folding a list of
commands over new/1.
Turn order
A turn that is accepted runs, in order: the player's action, then every living monster in
id order — a husk only every other turn — then a recomputed field of view, then the death
check. A monster strikes or moves in its turn, never both, and moves speed tiles; a
strike lands on d10 > 1 + defence, the player's too.
Creatures
Monsters start asleep and wake when they see the player; a sentry only when the player
comes within three tiles or strikes it, and a daemon also to the player's footsteps within
twelve (:wait is silent). One that has not seen the player for five turns settles
back to sleep. A mite below a third of its integrity runs. Sentries never move. From
stratum 3 a daemon guards the gate's room. Some carry a fragment of the gate's code
(fragment_count/1 of them a stratum), dropped where they die; known_code/1 reads
the fragments in hand. A command the world refuses — walking into a wall,
descending off a stair, equipping what is not owned — costs no turn, leaves the state
otherwise unchanged, and adds a message rather than raising.
Vision
:visible is what the player can see this instant; :seen is the union of everything ever
visible on the current stratum. Both are cleared on descending. Callers drawing the map
should treat a tile in :seen but not :visible as remembered terrain with no occupants.
The gate
This module does not open gates. Scriber.Console accepts an access code and writes a
marker, and the caller then invokes unseal/1, which turns the :gate tile into a
:stair.
As a Cauldron2D.Game
A Cauldron2D.World started with tick: :on_input runs a game of this module one step
per input: handle_input/3 keeps the actions newly pressed by the one player who joined
first, and step/2 turns each into a command/2 — actions/0 lists them, command_for/1
says which command each is. :use descends on a stair and says so elsewhere;
:throw_fuse and :throw_decoy throw at the input's aim, :pulse triggers one;
:unseal is unseal/1, held by the application when the gate's marker appears on disk;
the forge actions of Scriber.Forge.actions/0 buy and sell. view/2 is the game.
drain_events/1 gives the turn's sounds and happenings, placed where they happened:
{:step, kind, pos}, {:noticed, kind, pos}, {:chatter, kind, pos}, {:hit, weapon, kind, pos}, {:miss, kind, pos}, {:hurt, amount}, :parry, {:stunned, kind, pos},
{:died, kind, pos}, {:pickup, item}, :patch, :refused, :door, {:fuse, pos},
{:decoy, pos}, :pulse, {:unsealed, pos}, {:descended, depth}.
Example
Scriber.Game.new(seed: 99)
|> Scriber.Game.command({:move, 1, 0})
|> Scriber.Game.command(:wait)
Summary
Functions
The actions handle_input/3 reads: the moves, :wait, :apply_patch, :descend, :use, the equips, :unseal and the forge's.
Prepend {text, tone} to the message log.
Whether the player is standing on the stratum's console tile.
Whether the player is standing on a stair.
The current stratum's {width, height} in tiles, for clamping the camera to it.
A one-argument function telling Cauldron2D.Surface what to draw at a coordinate.
Resolve one turn from the player's command, returning the game after it.
The command/2 an action stands for, or nil for one step/2 handles itself.
Generate the stratum at depth, put the player at its spawn, and populate it.
The deepest stratum. Descending from it ends the run with status: :escaped.
How many fragments the gate's code is split into on a stratum: two, then three, then four.
How many items a stratum at depth carries in total.
The gate's code with the characters the fragments in hand give, ? for the rest.
Start a run on stratum 1, with the player placed, monsters and items scattered, and the opening messages logged.
The living monster standing on point, or nil if none is.
The least number of a stratum's items that are guaranteed to be patches.
Recompute :visible from the player's position and union it into :seen.
How far the player sees on a stratum: 8 tiles, 6 from stratum 4 down.
The player's effective :power and :defence, as the sidebar shows them.
Whether any awake monster stands on one of the eight tiles around the player.
Open this stratum's gate, turning its :gate tile into a walkable :stair.
Whether this stratum's gate has been opened in the game state.
The weapon currently in hand, as a Scriber.Gear entry.
Types
@type command() :: {:move, integer(), integer()} | :wait | :descend | :apply_patch | {:equip, Scriber.Gear.name()} | {:throw, :fuse | :decoy, point()} | :pulse
@type item() :: :shard | :patch | :probe | :fuse | :decoy | :pulse | {:fragment, non_neg_integer()}
@type status() :: :playing | :dead | :escaped
@type t() :: %Scriber.Game{ aim: term(), decoys: term(), defence_bonus: term(), depth: pos_integer(), entities: [Scriber.Entity.t()], events: [term()], fragments: term(), fuses: term(), held: MapSet.t(atom()), items: %{required({integer(), integer()}) => item()}, level: Scriber.Level.t(), messages: [{String.t(), atom()}], next_id: pos_integer(), noise: term(), owned: term(), patches: non_neg_integer(), player: Scriber.Entity.t(), player_id: term() | nil, power_bonus: term(), pressed: [atom()], pulses: term(), rng: Cauldron2D.Rng.t(), seed: integer(), seen: MapSet.t(), shards: non_neg_integer(), status: status(), stunned: term(), turn: non_neg_integer(), visible: MapSet.t(), weapon: term() }
Functions
@spec actions() :: [atom()]
The actions handle_input/3 reads: the moves, :wait, :apply_patch, :descend, :use, the equips, :unseal and the forge's.
Prepend {text, tone} to the message log.
:messages is newest first and is capped at 200 entries; older ones are
dropped. tone is a hint for the caller's colouring — :system, :good, :hurt,
:warning and :combat are the tones this module raises.
Whether the player is standing on the stratum's console tile.
Whether the player is standing on a stair.
This is exactly the condition command(game, :descend) tests, so a caller can use it to
decide whether to offer the descend key.
@spec bounds(t()) :: {pos_integer(), pos_integer()}
The current stratum's {width, height} in tiles, for clamping the camera to it.
@spec cell_fun(t(), keyword()) :: (Scriber.Level.point() -> Cauldron2D.Surface.cell())
A one-argument function telling Cauldron2D.Surface what to draw at a coordinate.
The returned function answers, for a point:
{tile, occupants, nil}when the point is currently visible —occupantsis a list of at most one art name, the player's taking precedence over a monster's, and a monster's over an item's{tile, [], :dim}when the point has been seen but is not visible now — remembered terrain, drawn without whatever stands on it:voidwhen the point has never been seen, carrying no terrain at all
Build it once per frame and reuse it across the whole surface: the occupant index is computed when this is called, and the returned closure does only map lookups. It captures the game as it is now, so a stale one draws a stale frame.
Resolve one turn from the player's command, returning the game after it.
Accepted commands:
{:move, dx, dy}— step one tile, or attack whatever living entity is on the target tile. Walking into a sealed gate reports the gate's node state instead and costs no turn; walking into anything else solid is refused.:wait— spend the turn doing nothing.:apply_patch— spend one patch to heal 20, capped atmax_hp. Refused with no patches or at full health.{:equip, weapon}— take up a weapon. Refused unless the weapon is:unarmedor in:owned, refused if already held, and refused while an awake monster is adjacent.:descend— take the stair underfoot to the next stratum, or end the run withstatus: :escapedwhen already atfinal_depth/0. Refused off a stair. Descending raises the player's integrity by five.{:throw, :fuse | :decoy, point}— throw one at a visible tile within six: a fuse stuns everything within a tile of it for two turns, a decoy is a noise that draws what is awake for six. Refused with none in hand, or out of range or sight.:pulse— spend a pulse: everything at arm's reach is thrown back a tile and stunned for a turn.
A move into a stunned creature slips past it, the two exchanging tiles.
A game whose :status is not :playing is returned unchanged for every command, so a
caller need not check before dispatching.
A refused command costs no turn and adds a :warning message. Turn resolution never
raises for an unreachable target or an occupied tile.
The command/2 an action stands for, or nil for one step/2 handles itself.
@spec enter_stratum(t(), pos_integer()) :: t()
Generate the stratum at depth, put the player at its spawn, and populate it.
Returns a game whose level is fully built: monsters spawned, items scattered, and the field
of view computed. The player keeps hit points, weapon, shards, patches and bonuses; the
previous stratum's entities, items, :visible and :seen are all discarded, so a new
stratum always arrives unexplored.
Does not check that depth follows the current one, and does not log a message. Use
command(game, :descend) for an ordinary descent.
@spec final_depth() :: pos_integer()
The deepest stratum. Descending from it ends the run with status: :escaped.
@spec fragment_count(pos_integer()) :: pos_integer()
How many fragments the gate's code is split into on a stratum: two, then three, then four.
@spec item_count(pos_integer()) :: pos_integer()
How many items a stratum at depth carries in total.
Items land only on walkable tiles that no monster took, so a very cramped level may carry fewer than this.
The gate's code with the characters the fragments in hand give, ? for the rest.
Start a run on stratum 1, with the player placed, monsters and items scattered, and the opening messages logged.
Options
:seed— integer the run derives from. The same seed gives the same map, spawns and rolls. Drawn from system entropy when omitted, and always readable afterwards asgame.seed.
@spec occupant(t(), {integer(), integer()}) :: Scriber.Entity.t() | nil
The living monster standing on point, or nil if none is.
Only searches :entities, so it never returns the player even when point is the player's
own tile.
@spec patch_floor(pos_integer()) :: pos_integer()
The least number of a stratum's items that are guaranteed to be patches.
Every stratum places at least this many patches, spread evenly through the item positions rather than clustered. The remaining items are rolled from a weighted table of shards, patches, probes, fuses, decoys and pulses, so the actual patch count is never lower than this and is often higher.
Recompute :visible from the player's position and union it into :seen.
Called at the end of every accepted turn and on entering a stratum; a caller that moves the
player directly must call it before drawing. Tolerates a nil :seen.
@spec sight(pos_integer()) :: pos_integer()
How far the player sees on a stratum: 8 tiles, 6 from stratum 4 down.
@spec stats(t()) :: %{power: pos_integer(), defence: non_neg_integer()}
The player's effective :power and :defence, as the sidebar shows them.
Each is the value from the weapon in hand plus the matching purchased bonus. These are the
numbers combat actually uses; game.player.power and game.player.defense are not.
Whether any awake monster stands on one of the eight tiles around the player.
Open this stratum's gate, turning its :gate tile into a walkable :stair.
Call this once Scriber.Console has accepted the access code. Returns the game unchanged
when the gate is already open, so it is safe to call on every poll.
Whether this stratum's gate has been opened in the game state.
@spec weapon(t()) :: Scriber.Gear.t()
The weapon currently in hand, as a Scriber.Gear entry.