diff --git a/.github/run-tests.sh b/.github/run-tests.sh new file mode 100755 index 00000000..c22af79c --- /dev/null +++ b/.github/run-tests.sh @@ -0,0 +1,97 @@ +#!/bin/sh +# +# What CI runs once a build is standing. Called with CONFIGURATION set to +# the make flags that produced the binary, so this knows which one to run. +# +# Only a default build can be tested here. Every mode calls vInit() before +# it does anything, and notcurses will not open without a terminal - under +# Actions there is none, and it aborts. The default backend is plain ANSI +# on a file descriptor and does not care, so it runs headless, and the +# game logic it exercises is the same either way: the two backends differ +# only in how the screen is drawn. +# +# Nothing here reads the keyboard on purpose, but --test polls for a key +# to see whether it has been told to stop, and treats end-of-input as +# "stop". /dev/zero never ends, so it keeps going until the timeout. + +set -eu + +case "${CONFIGURATION:-}" in + *debug=1*) avanor=./avanor-d ;; + *) avanor=./avanor ;; +esac + +# The soak below is meant to be cut off - it has no end of its own. Only +# the timeout killing it, or a clean exit, counts as passing; anything +# else is the crash we are looking for. +SOAK_SECONDS=60 + +# macOS ships no timeout(1) - it is GNU coreutils, which Homebrew installs +# with every binary prefixed, so there it is gtimeout. +if command -v timeout >/dev/null 2>&1; then + timeout_cmd=timeout +elif command -v gtimeout >/dev/null 2>&1; then + timeout_cmd=gtimeout +else + echo "neither timeout nor gtimeout found - install GNU coreutils" >&2 + exit 1 +fi + +# Where each run's stderr is caught so it can be looked at afterwards. +complaints=$(mktemp) +trap 'rm -f "$complaints"' EXIT + +# Content faults - a Lua file naming something nothing declares, a +# handler that will not run - are reported on stderr and otherwise let +# the game carry on, because a player would rather have a world with one +# thing wrong in it than no world at all. CI has no such excuse: a run +# that says anything about the world has found a bug, so it fails here. +run() { + echo "==> $*" + "$@" /dev/null 2>"$complaints" || { + status=$? + cat "$complaints" >&2 + return $status + } + + if [ -s "$complaints" ]; then + echo "the engine complained about the content:" >&2 + cat "$complaints" >&2 + return 1 + fi +} + +# One location written and read back. +run "$avanor" --test-cereal + +# A world saved in one process and restored in another. +run "$avanor" --test-save +run "$avanor" --test-load + +# Two scripted worlds played to their end. These run the scheduler, the +# AI, combat and the whole Lua content stack for a few hundred thousand +# turns, and answer 1 if the scenario could not be set up at all. +run "$avanor" --simulate orc_attack --arg 1 -s 1 +run "$avanor" --simulate mushroom_errand --arg 2 -s 1 + +# The world left to run itself for a while, with no hero in it. +echo "==> $avanor --test -s 4711 (${SOAK_SECONDS}s)" +status=0 +"$timeout_cmd" "$SOAK_SECONDS" "$avanor" --test -s 4711 /dev/null \ + 2>"$complaints" || status=$? + +# 124 is timeout(1) saying it had to kill it, which is the world still +# turning when time ran out - the outcome we want. +if [ "$status" -ne 0 ] && [ "$status" -ne 124 ]; then + cat "$complaints" >&2 + echo "soak died with exit $status" >&2 + exit 1 +fi + +if [ -s "$complaints" ]; then + echo "the engine complained about the content:" >&2 + cat "$complaints" >&2 + exit 1 +fi + +echo "all tests passed" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..c34277ad --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,115 @@ +name: C/C++ CI + +on: + push: + pull_request: + branches: ["main"] + workflow_dispatch: + +# A new push to a branch makes the run already going on it pointless. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Every way the Makefile can be asked to build, under both compilers. + # + # The default configurations deliberately do *not* install notcurses: + # the point of the default backend is that it needs nothing but fmt and + # luajit, and the only way to keep that true is to build it on a machine + # where notcurses is absent. + # + # They are also the configurations that run the tests, because they are + # the ones that run without a terminal - see the test step below. + linux: + name: linux ${{ matrix.compiler }} ${{ matrix.configuration || 'release' }} + # Pinned rather than ubuntu-latest, which is still 24.04: sol2 was not + # packaged for Ubuntu until 26.04 (as sol2-dev). Move this forward when + # ubuntu-latest catches up. + runs-on: ubuntu-26.04 + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + configuration: ["", "debug=1", "notcurses=1", "notcurses=1 debug=1"] + include: + - compiler: gcc + cxx: g++ + - compiler: clang + cxx: clang++ + + steps: + - uses: actions/checkout@v7 + - name: Update apt cache + run: sudo apt update + - name: Install dependencies + run: sudo apt install -y libargparse-dev libcereal-dev libfmt-dev libluajit-5.1-dev libzstd-dev pkg-config sol2-dev + - name: Install notcurses + run: sudo apt install -y libnotcurses-dev libnotcurses++-dev + if: contains(matrix.configuration, 'notcurses=1') + - name: Build ${{ matrix.configuration || 'release' }} + run: make -j$(nproc) CX=${{ matrix.cxx }} LD=${{ matrix.cxx }} ${{ matrix.configuration }} + - name: Check the binary starts + run: ${{ contains(matrix.configuration, 'debug=1') && './avanor-d' || './avanor' }} --version + - name: Test + if: ${{ !contains(matrix.configuration, 'notcurses=1') }} + env: + CONFIGURATION: ${{ matrix.configuration }} + run: ./.github/run-tests.sh + + # Nothing here is run: the Windows console under Actions is not a + # console either backend will open, so this proves the two builds still + # compile and link, no more. + windows: + name: windows ${{ matrix.configuration || 'release' }} + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + configuration: ["", "debug=1", "notcurses=1", "notcurses=1 debug=1"] + defaults: + run: + shell: msys2 {0} + steps: + - uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + install: > + git + make + wget + mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-cereal + mingw-w64-ucrt-x86_64-fmt + mingw-w64-ucrt-x86_64-luajit + mingw-w64-ucrt-x86_64-notcurses + mingw-w64-ucrt-x86_64-pkgconf + mingw-w64-ucrt-x86_64-sol2 + mingw-w64-ucrt-x86_64-zstd + - uses: actions/checkout@v7 + - name: Build ${{ matrix.configuration || 'release' }} + run: make -j$(nproc) ${{ matrix.configuration }} + - name: Check the binary starts + run: ${{ contains(matrix.configuration, 'debug=1') && './avanor-d.exe' || './avanor.exe' }} --version + + macos: + name: macos ${{ matrix.configuration || 'release' }} + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + configuration: ["", "debug=1", "notcurses=1", "notcurses=1 debug=1"] + steps: + - uses: actions/checkout@v7 + - name: Install dependencies + run: brew install argparse cereal coreutils fmt luajit notcurses pkgconf sol2 wget zstd + - name: Build ${{ matrix.configuration || 'release' }} + run: make -j$(sysctl -n hw.ncpu) ${{ matrix.configuration }} + - name: Check the binary starts + run: ${{ contains(matrix.configuration, 'debug=1') && './avanor-d' || './avanor' }} --version + - name: Test + if: ${{ !contains(matrix.configuration, 'notcurses=1') }} + env: + CONFIGURATION: ${{ matrix.configuration }} + run: ./.github/run-tests.sh diff --git a/.gitignore b/.gitignore index 7881e958..f7c4ec41 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ dmp*.txt obj/ obj-d/ -# Fetched on Windows builds by the Makefile - see ARGPARSE_HEADER +# Fetched by the Makefile - see STC_HEADER and ARGPARSE_HEADER external/ # Generated by tools and IDEs diff --git a/Main.cpp b/Main.cpp index 1cd1f5a6..d510c751 100644 --- a/Main.cpp +++ b/Main.cpp @@ -235,7 +235,14 @@ static bool TestRealCreature() return false; } - std::cout << "TestRealCreature: runtime type is " << typeid(*original).name() << std::endl; + // Through a reference rather than typeid(*original): dereferencing a + // shared_ptr is a function call, and clang rightly points out that + // typeid evaluates its operand when the type is polymorphic. It is + // meant to - the runtime type is the whole point - and saying so this + // way asks for it without the warning. + const XCreature& creature = *original; + + std::cout << "TestRealCreature: runtime type is " << typeid(creature).name() << std::endl; const auto original_guid = original->guid(); const auto original_contain_size = original->contain.size(); @@ -454,9 +461,19 @@ static bool TestRealScheduler() auto entry = restored.Get(); pass = pass && entry && dynamic_cast(entry.get()) != nullptr; + // See TestRealCreature() above for why this goes through a reference. + // The name() a type_info hands back outlives everything here, so + // holding the pointer is safe. + const char* entry_type = "none"; + + if (entry) { + const XObject& object = *entry; + entry_type = typeid(object).name(); + } + std::cout << " scheduler time " << local_sched.GetTime() << " -> " << restored.GetTime() - << ", entry " << (entry ? typeid(*entry).name() : "none") + << ", entry " << entry_type << std::endl; // Genuine orphan (Cereal's plain-delete deleter, no Invalidate() diff --git a/Makefile b/Makefile index f4499b8b..659b6802 100644 --- a/Makefile +++ b/Makefile @@ -1,27 +1,37 @@ ############################################################################## -# Compiling Avanor: make {debug=1} # +# Compiling Avanor: make {debug=1} {notcurses=1} # # # # Just typing 'make' builds the release version for the host platform - the # -# Windows/*nix split (native MinGW-w64 vs. notcurses-on-*nix's own build) # -# is detected from the environment, not a flag: $(OS) is Windows_NT on any # -# native Windows shell, MSYS2's UCRT64/MINGW64/CLANG64 included. # -# Define xmingw=1 when cross-compiling a Windows binary from *nix with the # -# MinGW-w64 crosscompiler - everything else about the build is identical # -# either way: same notcurses/fmt/luajit/zstd stack via pkg-config. # +# Windows/*nix split is detected from the environment, not a flag: $(OS) is # +# Windows_NT on any native Windows shell, MSYS2's UCRT64/MINGW64/CLANG64 # +# included. Define xmingw=1 when cross-compiling a Windows binary from *nix # +# with the MinGW-w64 crosscompiler - everything else about the build is # +# identical either way. # # Define debug=1 when you want to build debug version of Avanor # # # -# Define stc=1 to swap notcurses for a plain-ANSI terminal backend built on # -# the header-only stc.hpp, for a Windows terminal where notcurses' ConPTY/ # -# terminfo negotiation doesn't come good (garbage escapes, or a crash). # -# Windows-only - it's built on conio.h and the Win32 console API. See # -# engine/global.cpp's USE_STC-guarded code for what it can't do that the # -# notcurses backend can (live resize, mainly). # +# The terminal is driven by plain ANSI escape sequences, on the header-only # +# stc.hpp plus the platform's own keyboard reading - Win32 console calls on # +# Windows, termios and poll() everywhere else. Nothing beyond fmt, LuaJIT # +# and zstd has to be found, which is what keeps the game easy to build and # +# to hand to somebody. # # # -# argparse and stc.hpp are both header-only and not packaged for MSYS2's # -# mingw environments (argparse is on Fedora/Debian/Ubuntu, at least) - a # -# Windows/xmingw=1 build fetches whichever it needs with wget into # -# external/ on demand, pinned to the versions below, so the build stays # -# reproducible without a manual install step. # +# Define notcurses=1 to build against notcurses instead, at the price of a # +# shared library to find, package and ship. What it buys is its own # +# terminal capability negotiation, and a live resize on Windows, where the # +# plain backend has no way to hear about one - everywhere else SIGWINCH # +# tells it and both handle resizing alike. See the USE_NOTCURSES-guarded # +# code in engine/global.cpp for exactly where the two differ. # +# # +# Both backends build the same avanor out of the same obj/, and nothing in # +# a source file says which one built it, so switching between them needs a # +# 'make clean' first. Without one make finds nothing to do and leaves the # +# binary already standing, whichever backend that was. # +# # +# argparse and stc.hpp are both header-only. stc.hpp is not packaged # +# anywhere, and argparse is missing from MSYS2's mingw environments (it is # +# on Fedora/Debian/Ubuntu, at least), so the build fetches whichever it # +# needs with wget into external/ on demand, pinned to the versions below, # +# so it stays reproducible without a manual install step. # # # # There are also targets for making tarballs with the sources and binaries # # Example: # @@ -39,10 +49,21 @@ ifeq ($(OS),Windows_NT) win = 1 endif -CX = g++ -CC = gcc -LD = g++ -CFLAGS = -std=c++17 -fsigned-char -pipe -Wall -Wextra +# Homebrew installs outside the compiler's default search path, so the +# header-only packages - sol2, cereal, argparse - are invisible without +# saying where they are. pkg-config finds the rest by itself. The prefix +# is asked for rather than assumed: it is /opt/homebrew on Apple silicon +# and /usr/local on Intel. +ifeq ($(shell uname -s),Darwin) + BREW_PREFIX := $(shell brew --prefix 2>/dev/null) + + ifneq ($(BREW_PREFIX),) + CFLAGS += -isystem $(BREW_PREFIX)/include + LDFLAGS += -L$(BREW_PREFIX)/lib + endif +endif + +CFLAGS += -std=c++17 -fsigned-char -pipe -Wall -Wextra -Werror -I. ifdef xmingw CX = x86_64-w64-mingw32-g++ @@ -62,6 +83,14 @@ endif OBJDIR = obj NAME = avanor +# Every directory and binary any configuration of this Makefile produces. +# Taken now, with ':=', because $(OBJDIR) and $(NAME) pick up suffixes +# further down according to the flags this particular run was given - +# expanded later, 'make debug=1 clean' would go looking for obj-d-d. +# `clean` uses these so that it clears the lot however it is invoked. +ALL_OBJDIRS := $(OBJDIR) $(OBJDIR)-d +ALL_NAMES := $(NAME) $(NAME)-d $(NAME).exe $(NAME)-d.exe + ARGPARSE_VERSION = v3.2 ARGPARSE_HEADER = external/argparse/argparse.hpp ARGPARSE_URL = https://raw.githubusercontent.com/p-ranav/argparse/$(ARGPARSE_VERSION)/include/argparse/argparse.hpp @@ -71,21 +100,16 @@ STC_VERSION = 85c2194fe861d411c9790ea5362953ff5ca1bcd0 STC_HEADER = external/stc.hpp STC_URL = https://raw.githubusercontent.com/illyigan/simple_term_colors/$(STC_VERSION)/include/stc.hpp -CFLAGS += -I. - -ifdef win - CFLAGS += -Iexternal -endif - -ifdef stc - CFLAGS += -DUSE_STC - CFLAGS += $(shell pkg-config --cflags fmt luajit) +# Whatever the build has to fetch for itself lands in external/ - stc.hpp +# always, argparse.hpp on Windows. +CFLAGS += -Iexternal $(shell pkg-config --cflags fmt luajit) +LIBS = $(shell pkg-config --libs fmt luajit) -lzstd - ifndef win - CFLAGS += -Iexternal - endif -else - CFLAGS += $(shell pkg-config --cflags fmt notcurses luajit) +# The terminal backend, and the only thing that changes what has to be +# installed. See the notes at the top of this file. +ifdef notcurses + CFLAGS += -DUSE_NOTCURSES $(shell pkg-config --cflags notcurses) + LIBS += $(shell pkg-config --libs notcurses++) endif VPATH = creature engine game helpers item lua magic map player @@ -109,12 +133,6 @@ SRCS = xlua.cpp api_actor.cpp api_world.cpp xweapon.cpp xtime.cpp xstring.cpp \ pattern_builder.cpp chambers_builder.cpp plain_builder.cpp \ delve_builder.cpp bodypart.cpp anycr.cpp ai_view.cpp -ifdef stc - LIBS = $(shell pkg-config --libs fmt luajit) -lzstd -else - LIBS = $(shell pkg-config --libs fmt notcurses++ luajit) -lzstd -endif - ifdef debug CFLAGS += -g OBJDIR := ${addsuffix -d,$(OBJDIR)} @@ -140,13 +158,10 @@ $(OBJDIR): mkdir $(OBJDIR) $(OBJDIR)/%.o: %.cpp - $(CX) -MMD $(CFLAGS) -c $< -o $@ - -$(OBJDIR)/%.o: %.c - $(CC) -MMD $(CFLAGS) -c $< -o $@ + $(CXX) -MMD $(CFLAGS) -c $< -o $@ $(NAME): $(OBJS) - $(LD) $(CFLAGS) -o $@ $^ $(LIBS) + $(CXX) $(LDFLAGS) -o $@ $^ $(LIBS) $(ARGPARSE_HEADER): mkdir -p $(dir $@) @@ -160,14 +175,19 @@ ifdef win $(OBJDIR)/Main.o: $(ARGPARSE_HEADER) endif -ifdef stc +ifndef notcurses $(OBJDIR)/global.o: $(STC_HEADER) endif +# Clears every configuration, not just the one named on the command line: +# a clean that left the other one behind is how stale objects survive to +# confuse the next build, and telling the two backends apart needs a full +# clean anyway. Leaves external/ alone - those are downloads, not build +# output, and throwing them away only means fetching them again. clean: - $(RM) $(OBJDIR)/*.o - $(RM) $(OBJDIR)/*.d - $(RM) $(NAME) + $(RM) $(addsuffix /*.o,$(ALL_OBJDIRS)) + $(RM) $(addsuffix /*.d,$(ALL_OBJDIRS)) + $(RM) $(ALL_NAMES) source-zip: # create zip archive with Avanor sources, requires subversion command line client diff --git a/README.md b/README.md index 49c15c5f..e1cafd97 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ vendored into the tree and there are no git submodules, so a plain | ----------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | | **LuaJIT 2.1** | shared library | The scripting runtime. Every creature, item, location and quest in `world/` is Lua. | `pkg-config luajit` | | **sol2 3.x** | header only | The C++/Lua binding layer - usertypes, enum tables, protected calls. `#include ` | `/usr/include/sol` | -| **notcurses 3.x** | shared library | The terminal display, including 24-bit colour and the resize handling. The C++ binding `notcurses++` is what is linked; the game includes ``. | `pkg-config notcurses++` | +| **stc.hpp** | header only | The terminal display - plain ANSI escape sequences, with the keyboard read through the Win32 console API or termios. Fetched by the build, nothing to install. | `external/stc.hpp` | | **{fmt} 9+** | shared library | String formatting throughout the engine and the world scripts' messages. | `pkg-config fmt` | | **cereal 1.3** | header only | Save and restore. The whole world graph goes through it. `#include ` | `/usr/include/cereal` | | **Zstandard** | shared library | Compresses the save file (`avanor.svg.zst`). | `-lzstd` | @@ -218,22 +218,30 @@ GCC 9 or Clang 8 upwards will do. It is built and tested with GCC. The versions this is currently developed against: ``` -LuaJIT 2.1 notcurses 3.0.17 fmt 11.2.0 -sol2 3.5.0 cereal 1.3.2 zstd 1.5.7 +LuaJIT 2.1 fmt 11.2.0 zstd 1.5.7 +sol2 3.5.0 cereal 1.3.2 ``` On Fedora: ```bash sudo dnf install gcc-c++ make pkgconf-pkg-config \ - luajit-devel notcurses-devel fmt-devel \ - libzstd-devel sol2-devel cereal-devel argparse-devel + luajit-devel fmt-devel libzstd-devel \ + sol2-devel cereal-devel argparse-devel ``` -On Debian and Ubuntu the header-only three (sol2, cereal, argparse) may not -be packaged under those names; dropping their headers under -`/usr/local/include` works just as well, since the build finds them on the -default include path. +On Debian and Ubuntu: + +```bash +sudo apt install g++ make pkg-config \ + libluajit-5.1-dev libnotcurses-dev libnotcurses++-dev \ + libfmt-dev libzstd-dev sol2-dev libcereal-dev libargparse-dev +``` + +Note the names differ from Fedora's, and so does what is available when: +`sol2-dev` arrived in 26.04, `libargparse-dev` in 24.04. On an older release +drop those headers under `/usr/local/include` instead - the build finds them +on the default include path. ### Building @@ -243,13 +251,31 @@ make debug=1 -j$(nproc) # debug build with symbols -> ./avanor-d ``` The two builds keep their objects apart (`obj/` and `obj-d/`), so they do -not tread on each other. `make clean` only clears the configuration you -name, so a full clean is both: +not tread on each other. `make clean` clears both of them and both +binaries, whichever flags you pass it; the headers the build fetches into +`external/` are left alone. + +#### The notcurses backend + +The terminal is driven by plain ANSI escape sequences, which is why the +list above has no display library in it: there is nothing to find, package +or ship. `notcurses=1` builds against +[notcurses](https://github.com/dankamongmen/notcurses) instead. What that +buys is its own terminal capability negotiation, and a live resize on +Windows - on Linux and macOS SIGWINCH tells the plain backend and both +follow the window alike: ```bash -make clean && make debug=1 clean +sudo dnf install notcurses-devel # or libnotcurses++-dev on Debian/Ubuntu +make notcurses=1 -j$(nproc) ``` +Both backends produce the same `avanor` out of the same `obj/`, and nothing +in a source file says which one built it, so switching between them needs a +`make clean` first - without one make finds nothing to do and leaves the +binary already standing. The two sit side by side in `engine/global.cpp` +under `USE_NOTCURSES`. + ### Running ```bash diff --git a/creature/xhero.h b/creature/xhero.h index 9d35941e..ab1be457 100644 --- a/creature/xhero.h +++ b/creature/xhero.h @@ -207,13 +207,12 @@ class XHero final : public XCreature class XGuiItem_Inventory final : public XGuiItem { - XItem* pItem; std::string str; public: // show_price swaps the trailing badge from the // item's weight to its total gp value. explicit XGuiItem_Inventory(XItem* item, bool worn = false, bool show_price = false, - int unpaid = 0) : pItem(item) + int unpaid = 0) { str = "" + item->toString(); diff --git a/engine/global.cpp b/engine/global.cpp index 368bfc0d..c95bd1fd 100644 --- a/engine/global.cpp +++ b/engine/global.cpp @@ -33,12 +33,18 @@ Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. #include -// Two interchangeable terminal backends, chosen at compile time (-DUSE_STC): -// notcurses, the default everywhere, and a plain-ANSI backend built on the -// header-only stc.hpp for when notcurses' ConPTY/terminfo negotiation -// doesn't work out on a given Windows terminal - see engine/global.h and -// the Makefile's `stc` variable. -#ifdef USE_STC +// Two interchangeable terminal backends, chosen at compile time. The +// default is a plain-ANSI one built on the header-only stc.hpp: it asks +// nothing of the system but a terminal that understands escape sequences, +// which is what keeps the game simple to build and to hand to somebody. +// +// Building with notcurses=1 defines USE_NOTCURSES and takes the other +// path instead, at the cost of a shared library to find, package and +// ship. What it brings is its own capability negotiation, and a live +// resize on Windows - see vUpdateScreenSize() for why the plain backend +// cannot hear about one there, and why everywhere else it can. +// See engine/global.h and the Makefile's `notcurses` variable. +#ifndef USE_NOTCURSES #ifdef _WIN32 #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN @@ -119,7 +125,7 @@ int tile_jitter = 10; int tile_hue_jitter = 12; int tile_saturation_jitter = 15; -#ifdef USE_STC +#ifndef USE_NOTCURSES namespace { @@ -476,7 +482,7 @@ void vPutCh(int x, int y, char ch) screen->putc(y, x, ch < ' ' ? ' ' : ch); }; -#endif // USE_STC +#endif // !USE_NOTCURSES void vGotoXY(int x, int y) { @@ -553,7 +559,7 @@ void vDelay(const int n) std::this_thread::sleep_for(std::chrono::milliseconds(n)); } -#ifdef USE_STC +#ifndef USE_NOTCURSES #ifdef _WIN32 int vKbhit() @@ -737,6 +743,8 @@ int vGetch() // CSI or SS3: digits and separators, then a letter that says which // key it was. + constexpr int PARAM_MAX = 9999; + int param = 0; bool have_param = false; @@ -748,7 +756,16 @@ int vGetch() } if (b >= '0' && b <= '9') { - param = param * 10 + (b - '0'); + // Every parameter this decodes is one or two digits. A + // longer run is a terminal talking nonsense, or something + // that is not a terminal at all, and is stopped here + // rather than left to overflow: nothing downstream reads + // a number this large, so pinning it loses nothing and + // keeps the arithmetic defined. + if (param <= PARAM_MAX / 10) { + param = param * 10 + (b - '0'); + } + have_param = true; continue; } @@ -859,7 +876,7 @@ int vGetch() } } -#endif // USE_STC +#endif // !USE_NOTCURSES int vXGetch(const char* ch_buf) { @@ -880,7 +897,7 @@ int vXGetch(const char* ch_buf) } } -#ifdef USE_STC +#ifndef USE_NOTCURSES void vXGotoXY(int x, int y) { @@ -907,7 +924,7 @@ void vHideCursor() nc->cursor_disable(); } -#endif // USE_STC +#endif // !USE_NOTCURSES namespace { @@ -1414,7 +1431,7 @@ long vRand(unsigned long n) } } -#ifdef USE_STC +#ifndef USE_NOTCURSES V_BUFFER::V_BUFFER() { @@ -1517,7 +1534,7 @@ void vRestore(const V_BUFFER* buf) } } -#endif // USE_STC +#endif // !USE_NOTCURSES std::string vMakePath(std::string_view prefix, std::string_view filename) { diff --git a/engine/global.h b/engine/global.h index 187f2ed0..ba1bc449 100644 --- a/engine/global.h +++ b/engine/global.h @@ -36,9 +36,10 @@ Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. #define HOME_DIR "~/.avanor/" #endif -// The terminal is driven by notcurses on every platform this builds for - -// see engine/global.cpp. MSYS2/MinGW ships notcurses the same as *nix does, -// so Windows takes this path too rather than a Windows-only backend. +// The terminal is driven by plain ANSI escape sequences on every platform +// this builds for - see engine/global.cpp, which carries the Windows and +// the unixoid halves of that backend side by side. Building with +// notcurses=1 swaps in notcurses instead. #include #define stricmp(a, b) strcasecmp(a, b) #define strnicmp(a, b, n) strncasecmp(a, b, n) @@ -57,13 +58,6 @@ extern unsigned long cr_died; //global flags (users preferences) extern int __animation_flag; // 0 - none, other - delay milliseconds -#ifndef M_E - #define M_E 2.7182818284590452354 -#endif -#ifndef M_PI - #define M_PI 3.1415926535897932384 -#endif - // The sixteen colours Avanor was written in, as the colours they were // always meant to be rather than as terminal palette slots: brown is // brown here, not "yellow, but dim". A value IS an RGB triple, so @@ -280,10 +274,11 @@ constexpr char RGB_ESCAPE = 0x1E; constexpr int RGB_ESCAPE_LENGTH = 7; // A screen put aside while something is drawn over it - a menu, a -// character sheet - and blitted back afterwards. On the terminal it is a -// duplicate of the standard plane, made by notcurses itself. +// character sheet - and blitted back afterwards. What it holds is the +// backend's business: a copy of the cell grid by default, a duplicate of +// the standard plane made by notcurses itself under notcurses=1. struct V_BUFFER { - void* saved = nullptr; // ncplane*, the terminal's + void* saved = nullptr; // the backend's own, see engine/global.cpp V_BUFFER(); ~V_BUFFER(); diff --git a/engine/xlua.cpp b/engine/xlua.cpp index 143d59eb..9edb0734 100644 --- a/engine/xlua.cpp +++ b/engine/xlua.cpp @@ -18,17 +18,10 @@ along with this program; if not, write to the Free Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ +#include #include -#include "helpers/registry.h" #include "creature/anycr.h" -#include "item/xscroll.h" -#include "item/xbook.h" -#include "item/xenhance.h" -#include "item/xherb.h" -#include "item/itemdb.h" -#include "item/item_misc.h" -#include "item/xtool.h" #include "creature/bodypart.h" #include "creature/cr_defs.h" #include "creature/deity.h" @@ -36,16 +29,22 @@ Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. #include "engine/global.h" #include "engine/simulation.h" #include "engine/xlua.h" -#include "map/dungeon_builder.h" -#include "map/pattern.h" #include "game/location.h" -#include "lua/api_actor.h" -#include "lua/api_world.h" #include "game/quest.h" +#include "helpers/registry.h" #include "item/item.h" +#include "item/itemdb.h" #include "item/itemdef.h" +#include "item/item_misc.h" #include "item/xcorpse.h" #include "item/xpotion.h" +#include "item/xscroll.h" +#include "item/xbook.h" +#include "item/xenhance.h" +#include "item/xherb.h" +#include "item/xtool.h" +#include "lua/api_actor.h" +#include "lua/api_world.h" #include "magic/brand.h" #include "magic/effect.h" #include "magic/modifiers.h" @@ -53,9 +52,9 @@ Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. #include "magic/skill.h" #include "magic/stats.h" #include "magic/cskills.h" -#include - #include "map/map.h" +#include "map/dungeon_builder.h" +#include "map/pattern.h" lua_State* XLua::L = nullptr; @@ -261,10 +260,20 @@ void XLua::Init() MakeStrict(lua, enum_table); } - // Still the only two bindings registered through the raw Lua C API - // rather than sol2 - they take a lua_State* and hand-roll the stack. - - lua.open_libraries(sol::lib::base, sol::lib::string); + // What content may reach for. All three are pure computation - they + // touch nothing outside the Lua state - which is why they are here + // and the rest are not: + // + // math would bring math.random with it, and a world drawn + // from that instead of the engine's Rand() no longer + // follows from the seed it was built with. If content + // ever needs floor() or max(), open it and remove + // random/randomseed rather than leaving both. + // io, os files, processes and the clock. Content is data; + // package none of it has business reading the machine it runs + // debug on, and a world someone downloaded least of all. + // coroutine nothing has wanted it. + lua.open_libraries(sol::lib::base, sol::lib::string, sol::lib::table); // Sol2-bound Monster builder - registered before world scripts // load below, since world/creatures.lua calls it while loading. @@ -478,24 +487,9 @@ void XLua::Init() // Sol2-bound location/map-building functions // Registered before world scripts load below, since // locations.lua/valley.lua call these while loading. - { - lua_api::RegisterActorApi(lua); + lua_api::RegisterActorApi(lua); lua_api::RegisterWorldApi(lua); - } - - { - - - - - - - - - - } - lua.script_file("./world/init.lua"); // Catch Lua errors loading data. diff --git a/game/game.cpp b/game/game.cpp index eb1d7521..86b6e230 100644 --- a/game/game.cpp +++ b/game/game.cpp @@ -19,6 +19,7 @@ Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ #include +#include #include #include #include diff --git a/helpers/msgwin.cpp b/helpers/msgwin.cpp index d79adf2c..5b595d75 100644 --- a/helpers/msgwin.cpp +++ b/helpers/msgwin.cpp @@ -19,10 +19,10 @@ Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ #include +#include #include #include -#include #include #include "helpers/msgwin.h" diff --git a/helpers/xgui.cpp b/helpers/xgui.cpp index 43e3772a..440deb0d 100644 --- a/helpers/xgui.cpp +++ b/helpers/xgui.cpp @@ -23,6 +23,9 @@ Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. #include "helpers/xstring.h" #include +#include + +#include // The XGuiItem_Text::WideBuffer() function tries to widen the input string // str to the given width. If it is impossible (size already is equal or @@ -207,10 +210,18 @@ void XGuiList::Put(const std::optional> fi if (file) { vFPutS(file.value(), " "); } else { - char buf[256]; - sprintf(buf, "[%c]", i++ + 65); + // The letter that picks this line: [A], [B], [C]... + // Counted per screenful, not per list, so it runs past 'Z' + // only on a terminal tall enough to show twenty-seven + // selectable lines at once - and the key handler below + // reads the letter back the same way it is written here, + // so the two stay in step wherever ASCII takes them. + const std::string selector = + fmt::format("[{}]", + static_cast('A' + i++)); + vGotoXY(0, y_pos); - vPutS(buf); + vPutS(selector); selectable_items_count++; } } diff --git a/item/item.cpp b/item/item.cpp index 782752fa..66b8adfe 100644 --- a/item/item.cpp +++ b/item/item.cpp @@ -493,9 +493,17 @@ bool XItem::Compare(XObject* o) return it == tit->it && tit->aet == aet && XBaseObject::Compare(o); } +// Every item that can actually be looked at overrides this; the base is +// here only because XItem is not abstract. The return is not dead code +// everywhere it looks dead: glibc's assert ends in a noreturn call, so +// GCC knows control stops here, but MinGW's does not - and without a +// return the function would fall off its end, which is undefined rather +// than merely unreachable. std::string XItem::toString() { assert(0); + + return {}; } int XItem::ModifyDur(int val) diff --git a/item/item.h b/item/item.h index 5c4fc3dd..f35136c2 100644 --- a/item/item.h +++ b/item/item.h @@ -241,7 +241,7 @@ class XItem : public XBaseObject [[nodiscard]] std::string GetArtifactName(std::string real_name); }; -inline bool compare::operator()(const XItem* lhs, const XItem* rhs) const { +inline bool XItemCompare::operator()(const XItem* lhs, const XItem* rhs) const { if (lhs->kind != rhs->kind) { return lhs->kind < rhs->kind; } @@ -258,15 +258,15 @@ inline bool compare::operator()(const XItem* lhs, const XItem* rhs) const { return lhs < rhs; } -inline bool compare::operator()(const std::shared_ptr& lhs, const std::shared_ptr& rhs) const { +inline bool XItemCompare::operator()(const std::shared_ptr& lhs, const std::shared_ptr& rhs) const { return (*this)(lhs.get(), rhs.get()); } -inline bool compare::operator()(const std::shared_ptr& lhs, const XItem* rhs) const { +inline bool XItemCompare::operator()(const std::shared_ptr& lhs, const XItem* rhs) const { return (*this)(lhs.get(), rhs); } -inline bool compare::operator()(const XItem* lhs, const std::shared_ptr& rhs) const { +inline bool XItemCompare::operator()(const XItem* lhs, const std::shared_ptr& rhs) const { return (*this)(lhs, rhs.get()); } diff --git a/item/itemlist.h b/item/itemlist.h index e7da8b2e..cb5d6397 100644 --- a/item/itemlist.h +++ b/item/itemlist.h @@ -27,7 +27,7 @@ Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. /* Forward declaration */ class XItem; -struct compare { +struct XItemCompare { // Transparent so contain.erase(raw_ptr)/.find(raw_ptr) - pervasive // throughout the codebase - keep working by heterogeneous lookup once // XItemList holds shared_ptr instead of raw XItem*, without @@ -40,9 +40,9 @@ struct compare { bool operator()(const XItem* lhs, const std::shared_ptr& rhs) const; }; -class XItemList : public std::set, compare> +class XItemList : public std::set, XItemCompare> { - using Base = std::set, compare>; + using Base = std::set, XItemCompare>; // Returns an iterator to the stack item got merged into, or // end() if no matching stack exists. diff --git a/lua/api_actor.cpp b/lua/api_actor.cpp index 3dcc8b63..04a7beca 100644 --- a/lua/api_actor.cpp +++ b/lua/api_actor.cpp @@ -462,7 +462,7 @@ sol::optional FindCreature(const std::string& l_id, const std::string& gi return sol::nullopt; } -std::vector FindCreatures(const std::string& l_id, const std::string& gid, sol::optional x, sol::optional y, sol::optional w, sol::optional h) +sol::table FindCreatures(sol::this_state state, const std::string& l_id, const std::string& gid, sol::optional x, sol::optional y, sol::optional w, sol::optional h) { XRect rect(0, 0, Game.Location(l_id)->map->len, Game.Location(l_id)->map->hgt); @@ -473,14 +473,15 @@ std::vector FindCreatures(const std::string& l_id, const std::string& gid rect.bottom = rect.top + *h; } - std::vector result; + sol::state_view lua(state); + sol::table result = lua.create_table(); for (int i = rect.left; i < rect.right; i++) for (int j = rect.top; j < rect.bottom; j++) { XCreature* cr = Game.Location(l_id)->map->GetMonster(i, j); if (cr && cr->groupID() == gid) { - result.push_back(cr); + result.add(static_cast(cr)); } } diff --git a/lua/api_actor.h b/lua/api_actor.h index 4accc89f..721d2c56 100644 --- a/lua/api_actor.h +++ b/lua/api_actor.h @@ -129,7 +129,14 @@ namespace lua_api void SetAIFlag(void* cr, unsigned int flags); void SetEnemy(void* cr, const sol::object& cr_class); sol::optional FindCreature(const std::string& l_id, const std::string& gid, sol::optional x, sol::optional y, sol::optional w, sol::optional h); - std::vector FindCreatures(const std::string& l_id, const std::string& gid, sol::optional x, sol::optional y, sol::optional w, sol::optional h); + // A real Lua table, not the std::vector this used to hand back. sol2 + // pushes a container as userdata with container metamethods, and + // whether ipairs() will walk that depends on how the LuaJIT it is + // linked against was built: with LUAJIT_ENABLE_LUA52COMPAT it does, + // without it - Homebrew's plain upstream build, say - it answers + // "table expected, got userdata". Content should not turn on a flag + // of the Lua runtime, so everything handed to script is a table. + sol::table FindCreatures(sol::this_state state, const std::string& l_id, const std::string& gid, sol::optional x, sol::optional y, sol::optional w, sol::optional h); void ExecuteCreatureScript(void* cr, sol::table script); std::tuple GetWayXY(const std::string& l_id); void AddMessage(const std::string& str); diff --git a/lua/api_world.cpp b/lua/api_world.cpp index 868cc40e..3cbda1c4 100644 --- a/lua/api_world.cpp +++ b/lua/api_world.cpp @@ -771,7 +771,7 @@ sol::object PlaceObject(const std::string& id, const int x, const int y, if (!FindMapObject(id)) { std::cerr << "world: nothing defines a map object '" << id << "'" << std::endl; - return sol::nil; + return sol::lua_nil; } XLocation* l = ResolveLocation(location); @@ -780,13 +780,13 @@ sol::object PlaceObject(const std::string& id, const int x, const int y, // it would be written into this level's cell and read back from the // one underneath, so nothing would ever see it again. if (!l || !l->map->OwnsCell(x, y)) { - return sol::nil; + return sol::lua_nil; } auto* obj = new XLuaObject(id, x, y, l); if (!obj->isValid()) { - return sol::nil; + return sol::lua_nil; } return sol::make_object(s, static_cast(obj)); @@ -1064,7 +1064,7 @@ sol::object PlaceSpecial(const std::string& class_name, const int x, const int y auto* obj = dynamic_cast(XClassFactory::CreateNew(class_name)); if (!obj) { - return sol::nil; + return sol::lua_nil; } XLocation* l = ResolveLocation(location); @@ -1072,7 +1072,7 @@ sol::object PlaceSpecial(const std::string& class_name, const int x, const int y if (!l || !obj->PlaceAt(l, x, y)) { obj->Invalidate(); - return sol::nil; + return sol::lua_nil; } return sol::make_object(s, static_cast(obj)); diff --git a/magic/cskills.cpp b/magic/cskills.cpp index a44a9581..14c214f0 100644 --- a/magic/cskills.cpp +++ b/magic/cskills.cpp @@ -122,7 +122,7 @@ void CombatSkillBuilder::Register() int XCombatSkills::GetN(const int level) { //30, 26 - return 10 * std::lround((float)(25.0 * (pow(M_E, level / 4.0) - 1))); + return 10 * std::lround((float)(25.0 * (std::exp(level / 4.0) - 1))); } const std::string& XCombatSkills::GetName(const COMBAT_SKILL& cs) diff --git a/map/cave_builder.cpp b/map/cave_builder.cpp index 9d63f2b2..850439d7 100644 --- a/map/cave_builder.cpp +++ b/map/cave_builder.cpp @@ -25,6 +25,14 @@ Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. #include "map/cave_builder.h" #include "map/map.h" +// Written out rather than taken from M_PI, which is a POSIX extension the +// C++ standard does not require: glibc defines it, Windows' UCRT does not. +// std::numbers::pi is the C++20 answer, and this builds as C++17. Local +// because this is the only place in the game that needs it. +namespace { + constexpr double PI = 3.14159265358979323846; +} + void XCaveBuilder::Build() { int cl = w; @@ -64,8 +72,8 @@ void XCaveBuilder::Build() for (int angle = 0; angle < 360; angle += 3) { for (int r = 0; r < blob_radius; r++) { - const int tx = qx + (int)(r * cos(angle * M_PI / 180.0)); - const int ty = qy + (int)(r * sin(angle * M_PI / 180.0)); + const int tx = qx + (int)(r * cos(angle * PI / 180.0)); + const int ty = qy + (int)(r * sin(angle * PI / 180.0)); // Clipped against the map, not against the 80x20 one // this generator was written for. diff --git a/map/delve_builder.cpp b/map/delve_builder.cpp index cbdfde29..16688765 100644 --- a/map/delve_builder.cpp +++ b/map/delve_builder.cpp @@ -523,14 +523,14 @@ void randdigperm(DigPerm& digperm) // (rnd_i0(5) + (0 == rnd_i0(4))) ? rnd_i0(30) : 4 and almost always // takes the rnd_i0(30) arm - where the comment above says it should // usually be a few and only sometimes a few dozen. - const int c1000 = rnd_i0(5) + (0 == rnd_i0(4)) ? rnd_i0(30) : 4; + const int c1000 = (rnd_i0(5) + (0 == rnd_i0(4))) ? rnd_i0(30) : 4; for (int i = 0; i < c1000; i++) { setsymmetr(digperm, rnd_i0(256), 1000); } /* usually a few, sometimes a few dozen entries with 200 permil prob */ - const int c200 = rnd_i0(5) + (0 == rnd_i0(4)) ? rnd_i0(30) : 4; + const int c200 = (rnd_i0(5) + (0 == rnd_i0(4))) ? rnd_i0(30) : 4; for (int i = 0; i < c200; i++) { setsymmetr(digperm, rnd_i0(256), 200); diff --git a/world/simulations.lua b/world/simulations.lua index 3dbaf4a8..425a2f92 100644 --- a/world/simulations.lua +++ b/world/simulations.lua @@ -140,7 +140,7 @@ local function Farmers() for _, cr in ipairs(FindCreatures("MAIN", VILLAGE_GROUP)) do if (AsCreature(cr).name == FARMER_NAME) then - out[#out + 1] = cr + table.insert(out, cr) end end @@ -149,7 +149,7 @@ local function Farmers() for _, id in ipairs({"MUSHROOMS_CAVE1", "MUSHROOMS_CAVE2", "MUSHROOMS_CAVE5"}) do for _, cr in ipairs(FindCreatures(id, VILLAGE_GROUP)) do if (AsCreature(cr).name == FARMER_NAME) then - out[#out + 1] = cr + table.insert(out, cr) end end end