From d75e75b9783983eae4baa69fdea83762cbdaa920 Mon Sep 17 00:00:00 2001 From: treeform Date: Thu, 24 Sep 2026 10:58:31 -0700 Subject: [PATCH 1/6] Add player mailboxes to BASIC games --- .github/workflows/build.yml | 2 + coworld/tools/test_runtime.nim | 18 +++- docs/mailboxes.md | 74 +++++++++++++++ examples/call_to_adventure/bots.nim | 27 ++++-- examples/call_to_adventure/sim.nim | 4 +- examples/gods_of_the_arena/bots.nim | 24 ++++- examples/gods_of_the_arena/sim.nim | 4 +- examples/light_vs_dark/bots.nim | 31 +++++-- examples/light_vs_dark/sim.nim | 4 +- examples/mailboxes/chat.bas | 12 +++ src/polyworld/chats.nim | 80 ++++++++++++++++ src/polyworld/mailboxes.nim | 139 ++++++++++++++++++++++++++++ tests/test_chats.nim | 69 ++++++++++++++ tests/test_mailboxes.nim | 111 ++++++++++++++++++++++ tests/tests.nim | 2 + 15 files changed, 575 insertions(+), 26 deletions(-) create mode 100644 docs/mailboxes.md create mode 100644 examples/mailboxes/chat.bas create mode 100644 src/polyworld/chats.nim create mode 100644 src/polyworld/mailboxes.nim create mode 100644 tests/test_chats.nim create mode 100644 tests/test_mailboxes.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e27fe8aa..e2c4e829 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,8 @@ jobs: nimby install "${{ github.event.repository.name }}/${{ github.event.repository.name }}.nimble" nimby sync "${{ github.event.repository.name }}/nimby.lock" - run: nim r tests/tests.nim + - run: nim r -d:headless -d:mailboxCta tests/test_chats.nim + - run: nim r -d:headless -d:mailboxLvd tests/test_chats.nim - run: nim r tests/test_gota_events.nim - run: nim r -d:replayEvents tests/test_gota_controls.nim - run: nim r -d:replayEvents tests/test_gota_camps.nim diff --git a/coworld/tools/test_runtime.nim b/coworld/tools/test_runtime.nim index 50197699..46634994 100644 --- a/coworld/tools/test_runtime.nim +++ b/coworld/tools/test_runtime.nim @@ -101,7 +101,8 @@ proc episode( count: int, scripts: seq[string], failure = false, - ticks = 240 + ticks = 240, + expectedOutput = "" ) = ## Runs one local roster and inspects outputs at the completion marker. doAssert scripts.len == count @@ -178,7 +179,8 @@ proc episode( marker = directory / (if failure: "failure.json" else: "results.json") deadline = getMonoTime() + initDuration(seconds = 120) while not fileExists(marker): - doAssert process.running(), readFile(logPath) + doAssert process.running(), "exit " & $process.peekExitCode() & + ": " & readFile(logPath) doAssert getMonoTime() < deadline, "episode timed out" sleep(20) let output = readFile(marker).fromJson(JsonNode) @@ -186,6 +188,9 @@ proc episode( var logs: seq[string] for slot in 0 ..< count: logs.add readFile(directory / ("player-" & $slot & ".log")) + if expectedOutput.len > 0: + for private in logs: + doAssert private.contains(expectedOutput), private for slot, private in logs: doAssert private.len <= LogLimit let marker = "PRIVATE-" & $slot @@ -257,6 +262,15 @@ for (game, count) in Games: episode(game, count, scripts) echo game, ": runtime contracts passed" + for slot in 0 ..< scripts.len: + scripts[slot] = """ +sendChat(mailboxSelf(), "CHAT") +print pullMailbox$(), mailboxId() +""" + episode(game, count, scripts, ticks = 3, + expectedOutput = "CHAT") + echo game, ": hosted mailbox integration passed" + episode( "lvd", 2, diff --git a/docs/mailboxes.md b/docs/mailboxes.md new file mode 100644 index 00000000..b8bac628 --- /dev/null +++ b/docs/mailboxes.md @@ -0,0 +1,74 @@ +# Player mailboxes and chat + +Every player owns one private FIFO queue. There is no shared global queue or +team queue. Sending chooses which player queues receive their own copy: + +| `sendChat(id, text$)` destination | Routing | +| --- | --- | +| `-2` | All players, including the sender. | +| `-1` | The sender's teammates, including the sender. | +| `0` through `mailboxPlayers()-1` | Only that player. | + +Player IDs are zero-based roster slots, not game entity IDs. GotA uses 0 to 9, +CTA uses 0 to 3, and Light vs Dark uses 0 to 1. `mailboxSelf()` returns the +calling player's ID. The send function returns the number of player queues +that accepted the message, or zero if none did. + +`pullMailbox$()` removes the oldest message from the caller's own queue and +returns its text. It returns an empty string immediately if there is no mail. +After each successful pull, `mailboxId()` identifies that message: + +| Received `mailboxId()` | Meaning | +| --- | --- | +| `-2` | A global broadcast. | +| `-1` | A team broadcast. | +| Nonnegative | A DM from this player ID, not the recipient ID. | + +`mailboxSender()` also provides the actual sender for broadcasts. +`mailboxTick()` gives the send tick, `mailboxCount()` counts unread messages, +and `mailboxChannel$()` optionally returns `dm`, `team`, or `global`. +An empty pull clears the last envelope, making `mailboxId()` return -3, +`mailboxSender()` return -1, and `mailboxChannel$()` return an empty string. + +```basic +sendChat(-2, "Hello everyone.") +sendChat(-1, "Meet at the checkpoint.") +sendChat(0, "A direct message to player zero.") + +message$ = pullMailbox$() +while message$ <> "" + from = mailboxId() + print from, message$ + message$ = pullMailbox$() +wend +``` + +Delivery is immediate in send order. Scripts may send and poll whenever they +run, including multiple pulls during one decision. A later script in a tick +can read an earlier script's message in that tick. A player whose turn has +already ended reads it on its next decision. +`examples/mailboxes/chat.bas` demonstrates draining a queue and all three +destination types. + +`mailboxes.nim` owns the queues and fan-out rules. Each game's `Game.mailboxes` +owns the shared router, and each BASIC host is bound to its own player ID. +GotA supplies its red/blue teams, CTA puts the whole party on one team, and +each opposing Light vs Dark player has its own team. The generic router starts +with everyone on one team and no distance or visibility restrictions. + +A game can set `Game.mailboxes.rule`, a callback receiving the message and +each candidate recipient's zero-based ID. It returns true to deliver or false +to reject that copy. The game can consult its current world for proximity, +hearing range, visibility, alive state, or other rules. This runs after the +normal destination routing, so a range rule can narrow a global broadcast +or refuse a distant DM without granting access to another player's queue. +Changing `mailboxes.teams` updates team routing for future sends. Rules must +not mutate or recursively send through the router while checking delivery. + +Each queue holds at most 64 unread messages of up to 1024 UTF-8 bytes each. +Empty, invalid UTF-8, oversized, and invalid-destination messages are refused. +A full queue rejects new copies while retaining unread messages; other +recipients can still accept a broadcast. Messages persist until pulled and +are cleared on policy reload or a backward tick reset. Chat belongs to the +live agent session. Replay actions preserve resulting gameplay, but this port +does not store chat text in replays or add a graphical chat panel. diff --git a/examples/call_to_adventure/bots.nim b/examples/call_to_adventure/bots.nim index 42356bb0..353bc3f6 100644 --- a/examples/call_to_adventure/bots.nim +++ b/examples/call_to_adventure/bots.nim @@ -6,7 +6,8 @@ import bassy, - polyworld/[bodies, metrics, cli, controllers, pathing, profiles], + polyworld/[chats, mailboxes, bodies, metrics, cli, controllers, pathing, + profiles], content, sim, replays @@ -93,13 +94,14 @@ proc issueHeroAction(action: ReplayAction): int32 = proc heroLimits(): Limits = ## Defines one isolated hero VM's source, memory, and decision budgets. result = defaultLimits() + result.maxStringBytes = 128 * 1024 result.maxSourceBytes = 128 * 1024 result.maxCodeInstructions = 50_000 result.maxArrays = 32 result.maxArrayElements = 16_384 result.maxGlobals = 512 result.maxHostData = 32 - result.maxHostFunctions = 32 + result.maxHostFunctions = 64 result.maxRoutines = 64 result.maxParameters = 16 result.maxRegisters = 256 @@ -111,9 +113,11 @@ proc heroLimits(): Limits = result.maxPrintBytes = 4 * 1024 result.maxPrintEvents = 256 -proc buildHeroHost(heroId: int32): Host = +proc buildHeroHost(heroId: int32, chat: ChatHost = nil): Host = ## Builds the world-query and high-level action API for one hero. result = initHost() + let services = if chat == nil: newChatHost(0) else: chat + services.addFunctions(result) for name in HeroDataNames: discard result.addData(name) @@ -225,26 +229,32 @@ proc loadBots*( schema = buildHeroHost(100) kinds = controllerKinds(PartySize, playerSlot) sources = groups.expandBotSources(kinds) + game.mailboxes.reset(PartySize) var bound = false for slot in 0 ..< PartySize: if kinds[slot] == PlayerController: continue + let chat = newChatHost(slot) + chat.mailboxes = game.mailboxes + let source = sources[slot] let program = when defined(coworld): - compilePlayer(sources[slot], schema, limits, int(slot)) + compilePlayer(source, schema, limits, int(slot)) else: - compile(sources[slot], schema, limits) + compile(source, schema, limits) if not bound: bindHeroData(program) bound = true game.heroVms[slot] = HeroVm( runtime: initRuntime( program, - buildHeroHost(int32(100 + slot)), + buildHeroHost(int32(100 + slot), chat), limits ), - ready: true + ready: true, + prepareDecision: chat.decisionCallback(), ) + chat.bindRuntime(game.heroVms[slot].runtime) when defined(coworld): game.heroVms[slot].output = playerPrinter(int(slot)) @@ -262,6 +272,8 @@ proc runBotDecisions*(game: Game, slot: int32) {.measure.} = let objective = game.objectiveTile(slot) game.heroVms[slot].runtime.restart() try: + if game.heroVms[slot].prepareDecision != nil: + game.heroVms[slot].prepareDecision(game.world.tick) game.heroVms[slot].runtime.setData(heroDataIds[DataSelfId], actor.id) game.heroVms[slot].runtime.setData( heroDataIds[DataSelfClass], @@ -329,4 +341,3 @@ proc runBotDecisions*(game: Game, slot: int32) {.measure.} = ) activeGame = nil activeHeroSlot = -1 - diff --git a/examples/call_to_adventure/sim.nim b/examples/call_to_adventure/sim.nim index 5d9fe1e2..88aa3e95 100644 --- a/examples/call_to_adventure/sim.nim +++ b/examples/call_to_adventure/sim.nim @@ -11,7 +11,7 @@ import bassy, fixxy, polyworld/[bodies, hashes, metrics, pathing, profiles, rngs, tapes, - visions], + visions, mailboxes], content, maps, replays @@ -19,6 +19,7 @@ import type HeroVm* = ref object output*: PrintProc + prepareDecision*: proc(tick: int32) {.closure.} ## One compiled BASIC program for a party slot. Not simulation state. runtime*: Runtime ready*: bool @@ -41,6 +42,7 @@ type historyPlayback*: bool replayMode*: bool heroVms*: array[PartySize, HeroVm] + mailboxes*: Mailboxes const AggroTiles* = 9'i32 diff --git a/examples/gods_of_the_arena/bots.nim b/examples/gods_of_the_arena/bots.nim index 5a7e5b55..08f5a4fc 100644 --- a/examples/gods_of_the_arena/bots.nim +++ b/examples/gods_of_the_arena/bots.nim @@ -3,7 +3,8 @@ import bassy, fixxy, - polyworld/[metrics, bodies, cli, controllers, pathing, profiles, tapes], + polyworld/[chats, mailboxes, metrics, bodies, cli, controllers, pathing, + profiles, tapes], content, maps, motions, @@ -102,6 +103,7 @@ proc bindHeroData(program: Program) = proc heroVmLimits(): Limits = ## Returns independent structural and per-decision limits for a hero VM. result = defaultLimits() + result.maxStringBytes = 128 * 1024 result.maxSourceBytes = 64 * 1024 result.maxCodeInstructions = 20_000 result.maxArrays = 32 @@ -259,9 +261,11 @@ proc abilityProc(heroId: int32, field: AbilityField): HostProc = of AbilityRestore: spec.restore of AbilityManaCost: spec.manaCost -proc initHeroHost(heroId: int32): Host = +proc initHeroHost(heroId: int32, chat: ChatHost = nil): Host = ## Builds the bounded world-query and action interface for one hero. result = initHost() + let services = if chat == nil: newChatHost(0) else: chat + services.addFunctions(result) for error in ActionError: discard result.addData($error, error.ord.int32) for class in HeroClass: @@ -798,27 +802,35 @@ proc loadBots*( kinds = controllerKinds(game.world.heroes.len, playerSlot) sources = groups.expandBotSources(kinds) game.heroVms.setLen(game.world.heroes.len) + game.mailboxes.reset(game.world.heroes.len) + for i, hero in game.world.heroes: + game.mailboxes.teams[i] = int32(hero.team) var bound = false for i in 0 ..< game.world.heroes.len: if kinds[i] == PlayerController: continue + let chat = newChatHost(i) + chat.mailboxes = game.mailboxes + let source = sources[i] let program = when defined(coworld): - compilePlayer(sources[i], schema, limits, int(i)) + compilePlayer(source, schema, limits, int(i)) else: - compile(sources[i], schema, limits) + compile(source, schema, limits) if not bound: bindHeroData(program) bound = true game.heroVms[i] = HeroVm( runtime: initRuntime( program, - initHeroHost(game.world.heroes[i].id), + initHeroHost(game.world.heroes[i].id, chat), limits ), limits: limits, + prepareDecision: chat.decisionCallback(), ready: true ) + chat.bindRuntime(game.heroVms[i].runtime) when defined(coworld): game.heroVms[i].output = playerPrinter(int(i)) @@ -833,6 +845,8 @@ proc runHeroScript(game: Game, index: int) = return vm.runtime.restart() try: + if vm.prepareDecision != nil: + vm.prepareDecision(game.world.tick) discard game.world.worldObjectCount(hero.id) vm.runtime.setData(heroDataIds[DataSelfId], hero.id) vm.runtime.setData(heroDataIds[DataSelfTeam], int32(hero.team.ord)) diff --git a/examples/gods_of_the_arena/sim.nim b/examples/gods_of_the_arena/sim.nim index e9578b22..77a9cbcf 100644 --- a/examples/gods_of_the_arena/sim.nim +++ b/examples/gods_of_the_arena/sim.nim @@ -12,7 +12,7 @@ import std/algorithm, bassy, fixxy, polyworld/[bodies, hashes, metrics, noises, pathing, profiles, rngs, tapes, - visions], + visions, mailboxes], content, events, motions, maps, replays @@ -67,6 +67,7 @@ type HeroVm* = ref object output*: PrintProc + prepareDecision*: proc(tick: int32) {.closure.} runtime*: Runtime limits*: Limits ready*: bool @@ -320,6 +321,7 @@ type replayMode*: bool recordingError*: string heroVms*: seq[HeroVm] + mailboxes*: Mailboxes nextFootmen: seq[Footman] nextHeroes: seq[Hero] collisionUnits: seq[CollisionUnit] diff --git a/examples/light_vs_dark/bots.nim b/examples/light_vs_dark/bots.nim index c7ce0985..02d620a9 100644 --- a/examples/light_vs_dark/bots.nim +++ b/examples/light_vs_dark/bots.nim @@ -12,7 +12,7 @@ import bassy, - polyworld/[bodies, metrics, profiles], + polyworld/[chats, mailboxes, bodies, metrics, profiles], content, sim @@ -243,13 +243,14 @@ proc overlordLimits*(): Limits = ## unit will exceed it and fail the script, which is the pressure that ## pushes authors onto `nearestEnemy` and friends. result = defaultLimits() + result.maxStringBytes = 128 * 1024 result.maxSourceBytes = 256 * 1024 result.maxCodeInstructions = 100_000 result.maxArrays = 64 result.maxArrayElements = 65_536 result.maxGlobals = 1_024 result.maxHostData = 64 - result.maxHostFunctions = 64 + result.maxHostFunctions = 128 result.maxRoutines = 128 result.maxParameters = 16 result.maxRegisters = 512 @@ -267,7 +268,7 @@ proc observedAt(index: int32): Observed = return Observed(owner: -1) snapshot[index] -proc buildOverlordHost*(playerId: int32): Host = +proc buildOverlordHost*(playerId: int32, chat: ChatHost = nil): Host = ## Builds the complete world-query and command interface for one player. ## ## The same builder makes both the compile-time schema and each player's @@ -280,6 +281,8 @@ proc buildOverlordHost*(playerId: int32): Host = ## cost far more than their own cycles, so a script's budget prices its ## demand on the simulation rather than only its own arithmetic. result = initHost() + let services = if chat == nil: newChatHost(0) else: chat + services.addFunctions(result) for name in OverlordDataNames: discard result.addData(name) @@ -541,24 +544,34 @@ proc buildOverlordHost*(playerId: int32): Host = ## Lifecycle -proc loadBots*(game: Game, sources: array[PlayerCount, string]) = +proc loadBots*( + game: Game, sources: array[PlayerCount, string] +) = ## Compiles one script per player and gives each its own runtime. let limits = overlordLimits() let schema = buildOverlordHost(0) + game.mailboxes.reset(PlayerCount) + for player in 0 ..< PlayerCount: + game.mailboxes.teams[player] = int32(player) var bound = false for player in 0'i32 ..< PlayerCount: when not defined(coworld): if sources[player].len == 0: continue + let chat = newChatHost(int(player)) + chat.mailboxes = game.mailboxes + let source = sources[player] let program = when defined(coworld): - compilePlayer(sources[player], schema, limits, int(player)) + compilePlayer(source, schema, limits, int(player)) else: - compile(sources[player], schema, limits) + compile(source, schema, limits) game.brains[player] = OverlordVm( - runtime: initRuntime(program, buildOverlordHost(player), limits), - ready: true + runtime: initRuntime(program, buildOverlordHost(player, chat), limits), + ready: true, + prepareDecision: chat.decisionCallback(), ) + chat.bindRuntime(game.brains[player].runtime) if not bound: bindOverlordData(program) bound = true @@ -584,6 +597,8 @@ proc runDecision(game: Game, player: int32) = game.brains[player].runtime.restart() try: + if game.brains[player].prepareDecision != nil: + game.brains[player].prepareDecision(game.world.tick) let economy = addr game.world.players[player] ids = overlordDataIds diff --git a/examples/light_vs_dark/sim.nim b/examples/light_vs_dark/sim.nim index 21b888c8..3ebdb07d 100644 --- a/examples/light_vs_dark/sim.nim +++ b/examples/light_vs_dark/sim.nim @@ -7,7 +7,7 @@ import bassy, fixxy, polyworld/[bodies, hashes, metrics, pathing, profiles, rngs, tapes, - visions], + visions, mailboxes], content, maps, replays @@ -133,6 +133,7 @@ type explored*: array[PlayerCount, seq[uint8]] # HASH: derived OverlordVm* = ref object output*: PrintProc + prepareDecision*: proc(tick: int32) {.closure.} ## One compiled BASIC program for a player. Not simulation state. runtime*: Runtime ready*: bool @@ -153,6 +154,7 @@ type historyPlayback*: bool replayMode*: bool brains*: array[PlayerCount, OverlordVm] + mailboxes*: Mailboxes mapSeed*: int32 maximumTicks*: int32 diff --git a/examples/mailboxes/chat.bas b/examples/mailboxes/chat.bas new file mode 100644 index 00000000..bee2f82b --- /dev/null +++ b/examples/mailboxes/chat.bas @@ -0,0 +1,12 @@ +' Drain all unread messages. Empty text means the queue is empty. +message$ = pullMailbox$() +while message$ <> "" + print mailboxId(), mailboxSender(), mailboxTick(), message$ + message$ = pullMailbox$() +wend +if announced = 0 then + sendChat(-2, "Hello everyone.") + sendChat(-1, "Hello teammates.") + sendChat(mailboxSelf(), "A private note to myself.") + announced = 1 +end if diff --git a/src/polyworld/chats.nim b/src/polyworld/chats.nim new file mode 100644 index 00000000..14138309 --- /dev/null +++ b/src/polyworld/chats.nim @@ -0,0 +1,80 @@ +import + bassy, + mailboxes + +type + ChatHost* = ref object + runtime {.cursor.}: Runtime + slot: int + mailboxes*: Mailboxes + ChatFunction = enum + SendChat, PullMailbox, MailboxSender, MailboxChannel, MailboxTick, + MailboxCount, MailboxSelf, MailboxPlayers, MailboxId + +const + FunctionNames: array[ChatFunction, string] = [ + "sendChat", "pullMailbox$", "mailboxSender", "mailboxChannel$", + "mailboxTick", "mailboxCount", "mailboxSelf", "mailboxPlayers", "mailboxId" + ] + FunctionParameters: array[ChatFunction, int] = [2, 0, 0, 0, 0, 0, 0, 0, 0] + +proc newChatHost*(slot: int, mailboxes: Mailboxes = nil): ChatHost = + ## Binds a player's BASIC host to its own mailbox address. + ChatHost(slot: slot, mailboxes: mailboxes) + +proc bindRuntime*(host: ChatHost, runtime: Runtime) = + ## Borrows the runtime owning these callbacks without a reference cycle. + host.runtime = runtime + +proc beginTick*(host: ChatHost, tick: int32) = + ## Advances message timestamps without consuming unread mail. + host.mailboxes.beginTick(tick) + +proc decisionCallback*(host: ChatHost): proc(tick: int32) = + ## Binds mailbox preparation to the game's decision boundary. + result = proc(tick: int32) = + ## Advances the shared router for this decision. + host.beginTick(tick) + +proc callback(host: ChatHost, kind: ChatFunction): NumericHostProc = + ## Exposes only this player's queue to the BASIC runtime. + result = proc(arguments: openArray[Value]): Value = + ## Converts bounded BASIC strings and mailbox addresses. + template output(value: string): Value = + ## Stores returned message text in BASIC's bounded string pool. + host.runtime.putString(value) + case kind + of SendChat: + result = host.mailboxes.send( + host.slot, + int(arguments[0].asInt()), + host.runtime.getString(arguments[1]) + ) + of PullMailbox: + result = output(host.mailboxes.pull(host.slot).text) + of MailboxSender: + let last = host.mailboxes.last(host.slot) + result = int32(if last.text.len == 0: -1 else: last.sender) + of MailboxChannel: + let last = host.mailboxes.last(host.slot) + result = output(if last.text.len == 0: "" else: last.channel.channelName()) + of MailboxTick: + result = host.mailboxes.last(host.slot).tick + of MailboxCount: + result = host.mailboxes.count(host.slot) + of MailboxSelf: + result = int32(host.slot) + of MailboxPlayers: + result = int32(host.mailboxes.players()) + of MailboxId: + result = int32(host.mailboxes.last(host.slot).id()) + +proc addFunctions*(host: ChatHost, basic: var Host) = + ## Registers player-to-player communication without external services. + for kind in ChatFunction: + discard basic.addFunction( + FunctionNames[kind], + FunctionParameters[kind], + host.callback(kind), + 256 + ) diff --git a/src/polyworld/mailboxes.nim b/src/polyworld/mailboxes.nim new file mode 100644 index 00000000..cc015175 --- /dev/null +++ b/src/polyworld/mailboxes.nim @@ -0,0 +1,139 @@ +import std/unicode + +const + MaxMailboxPlayers* = 64 + MaxMailboxMessages* = 64 + MaxChatBytes* = 1024 + GlobalMailboxId* = -2 + TeamMailboxId* = -1 + NoMailboxId* = -3 + +type + MailboxError* = object of CatchableError + MailChannel* = enum + DirectMailbox, TeamMailbox, GlobalMailbox + MailMessage* = object + sender*, target*: int + tick*: int32 + channel*: MailChannel + text*: string + MailRule* = proc(message: MailMessage, recipient: int): bool {.closure.} + Mailbox = object + messages: array[MaxMailboxMessages, MailMessage] + first, count: int + last: MailMessage + Mailboxes* = ref object + teams*: seq[int32] + rule*: MailRule + boxes: seq[Mailbox] + tick: int32 + +proc newMailboxes*(players: int): Mailboxes = + ## Creates private queues with an unrestricted common team by default. + if players < 1 or players > MaxMailboxPlayers: + raise newException(MailboxError, "Mailbox player count must be 1 .. 64") + Mailboxes( + teams: newSeq[int32](players), boxes: newSeq[Mailbox](players), tick: -1 + ) + +proc players*(mailboxes: Mailboxes): int = + ## Reports the number of zero-based player addresses. + if mailboxes == nil: 0 else: mailboxes.boxes.len + +proc clear*(mailboxes: Mailboxes) = + ## Releases unread and last-read messages without changing game rules. + for box in mailboxes.boxes.mitems: + box = Mailbox() + +proc reset*(mailboxes: var Mailboxes, players: int) = + ## Starts a new roster while retaining rules for an unchanged game size. + if mailboxes == nil or mailboxes.players != players: + mailboxes = newMailboxes(players) + else: + mailboxes.clear() + mailboxes.tick = -1 + +proc beginTick*(mailboxes: Mailboxes, tick: int32) = + ## Preserves unread mail across ticks and clears it on a match reset. + if mailboxes == nil: + return + if tick < mailboxes.tick: + mailboxes.clear() + mailboxes.tick = tick + +proc channelName*(channel: MailChannel): string = + ## Returns the BASIC channel name associated with an envelope. + case channel + of DirectMailbox: "dm" + of TeamMailbox: "team" + of GlobalMailbox: "global" + +proc id*(message: MailMessage): int = + ## Returns a broadcast address or the sender of a direct message. + if message.text.len == 0: + return NoMailboxId + case message.channel + of DirectMailbox: message.sender + of TeamMailbox: TeamMailboxId + of GlobalMailbox: GlobalMailboxId + +proc send*( + mailboxes: Mailboxes, sender, target: int, text: string +): int32 = + ## Fans out a message through the game's audience and delivery rules. + if mailboxes == nil or sender < 0 or sender >= mailboxes.players or + text.len == 0 or text.len > MaxChatBytes or text.validateUtf8() >= 0: + return 0 + if target < GlobalMailboxId or target >= mailboxes.players: + return 0 + let channel = + case target + of GlobalMailboxId: GlobalMailbox + of TeamMailboxId: TeamMailbox + else: DirectMailbox + let message = MailMessage( + sender: sender, + target: target, + tick: mailboxes.tick, channel: channel, text: text + ) + for index, box in mailboxes.boxes.mpairs: + let recipient = index + case channel + of DirectMailbox: + if recipient != target: + continue + of TeamMailbox: + if mailboxes.teams[index] != mailboxes.teams[sender]: + continue + of GlobalMailbox: + discard + if box.count == MaxMailboxMessages: + continue + if mailboxes.rule != nil and not mailboxes.rule(message, recipient): + continue + box.messages[(box.first + box.count) mod MaxMailboxMessages] = message + inc box.count + inc result + +proc count*(mailboxes: Mailboxes, recipient: int): int32 = + ## Counts this recipient's unread messages without consuming them. + if mailboxes != nil and recipient in 0 ..< mailboxes.players: + result = int32(mailboxes.boxes[recipient].count) + +proc pull*(mailboxes: Mailboxes, recipient: int): MailMessage = + ## Pops one FIFO message, or returns an empty envelope when drained. + if mailboxes == nil or recipient notin 0 ..< mailboxes.players: + return + let box = addr mailboxes.boxes[recipient] + box.last = MailMessage() + if box.count == 0: + return + result = move(box.messages[box.first]) + box.last = result + box.first = (box.first + 1) mod MaxMailboxMessages + dec box.count + +proc last*(mailboxes: Mailboxes, recipient: int): MailMessage = + ## Reads metadata for the recipient's most recent pull operation. + if mailboxes != nil and recipient in 0 ..< mailboxes.players: + result = mailboxes.boxes[recipient].last diff --git a/tests/test_chats.nim b/tests/test_chats.nim new file mode 100644 index 00000000..aa8b47d7 --- /dev/null +++ b/tests/test_chats.nim @@ -0,0 +1,69 @@ +import + std/[os, strutils, tempfiles], + bassy, + polyworld/[cli, mailboxes] + +when defined(mailboxCta): + import ../examples/call_to_adventure/[bots, content, sim] +elif defined(mailboxLvd): + import ../examples/light_vs_dark/[bots, content, maps, sim] +else: + import ../examples/gods_of_the_arena/[bots, maps, replays, sim] + +const Program = """ +sent = sendChat(mailboxSelf(), "private hello") +message$ = pullMailbox$() +from = mailboxId() +""" + +echo "Testing mailbox functions through the game's actual BASIC hosts and loaders" +block: + let + directory = createTempDir("polyworld-mailboxes-", "") + path = directory / "player.bas" + defer: + removeDir(directory) + writeFile(path, Program) + when defined(mailboxCta): + let game = newGame(2026) + elif defined(mailboxLvd): + let game = newGame(generateMap(DefaultSeed), 240) + else: + let game = newGame(generateMap(54), 240, 10, false, ReplayData(), + drafting = false) + + when defined(mailboxLvd): + game.loadBots([Program, Program]) + elif defined(mailboxCta): + game.loadBots([BotGroup(path: path, count: PartySize)]) + else: + game.loadBots([BotGroup(path: path, count: 10)]) + doAssert game.mailboxes != nil + let teamCount = + when defined(mailboxCta): PartySize + elif defined(mailboxLvd): 1 + else: 5 + doAssert game.mailboxes.send(0, TeamMailboxId, "team") == teamCount + for slot in 0 ..< game.mailboxes.players: + let message = game.mailboxes.pull(slot) + doAssert (message.text == "team") == + (game.mailboxes.teams[slot] == game.mailboxes.teams[0]) + for tick in 1 .. 2: + game.world.tick = int32(tick) + when defined(mailboxCta): + for slot in 0'i32 ..< PartySize: + game.runBotDecisions(slot) + else: + game.runBotDecisions() + when defined(mailboxLvd): + let vms = game.brains + else: + let vms = game.heroVms + for index, vm in vms: + doAssert vm != nil and not vm.failed, vm.lastError + doAssert vm.runtime.getGlobal("sent") == 1 + doAssert vm.runtime.getGlobal("from") == index + doAssert vm.runtime.getString(vm.runtime.getGlobalValue("message$")) == + "private hello" + let large = vm.runtime.putString(repeat('x', 1024)) + doAssert vm.runtime.getString(large).len == 1024 diff --git a/tests/test_mailboxes.nim b/tests/test_mailboxes.nim new file mode 100644 index 00000000..22b9ae94 --- /dev/null +++ b/tests/test_mailboxes.nim @@ -0,0 +1,111 @@ +import + std/strutils, + bassy, + polyworld/[chats, mailboxes] + +echo "Testing private queues, broadcasts, DMs, and received message IDs" +block: + let mail = newMailboxes(3) + mail.beginTick(12) + doAssert mail.pull(0).text == "" + doAssert mail.last(0).id == NoMailboxId + doAssert mail.send(0, -2, "hello") == 3 + doAssert mail.send(1, 0, "private") == 1 + doAssert mail.pull(0).text == "hello" + doAssert mail.last(0).id == -2 + doAssert mail.last(0).sender == 0 + doAssert mail.pull(0).text == "private" + doAssert mail.last(0).id == 1 + doAssert mail.last(0).sender == 1 + doAssert mail.last(0).target == 0 + doAssert mail.last(0).tick == 12 + doAssert mail.pull(0).text == "" + doAssert mail.last(0).id == NoMailboxId + doAssert mail.pull(1).text == "hello" + doAssert mail.pull(2).text == "hello" + doAssert mail.count(1) == 0 and mail.count(2) == 0 + +echo "Testing team membership and per-recipient game rules" +block: + let mail = newMailboxes(3) + doAssert mail.send(0, -1, "default team") == 3 + doAssert mail.pull(2).id == -1 + mail.clear() + mail.teams = @[0'i32, 0'i32, 1'i32] + doAssert mail.send(0, -1, "allies") == 2 + doAssert mail.count(2) == 0 + mail.clear() + var positions = @[0, 1, 100] + mail.rule = proc(message: MailMessage, recipient: int): bool = + ## Models a game restricting every channel to local hearing range. + abs(positions[message.sender] - positions[recipient]) <= 5 + doAssert mail.send(0, -2, "nearby") == 2 + doAssert mail.send(0, 2, "too far") == 0 + positions[2] = 3 + doAssert mail.send(0, 2, "now near") == 1 + doAssert mail.pull(2).text == "now near" + +echo "Testing mailbox memory bounds, wraparound, and reset" +block: + let mail = newMailboxes(2) + mail.beginTick(10) + doAssert mail.send(0, 1, "") == 0 + doAssert mail.send(0, 2, "invalid target") == 0 + doAssert mail.send(0, -3, "invalid channel") == 0 + doAssert mail.send(0, 1, repeat('x', MaxChatBytes + 1)) == 0 + doAssert mail.send(0, 1, "\xff") == 0 + for i in 0 ..< MaxMailboxMessages: + doAssert mail.send(0, 1, $i) == 1 + doAssert mail.send(0, 1, "overflow") == 0 + for i in 0 ..< 10: + doAssert mail.pull(1).text == $i + doAssert mail.send(0, 1, $(i + MaxMailboxMessages)) == 1 + for i in 10 ..< MaxMailboxMessages + 10: + doAssert mail.pull(1).text == $i + doAssert mail.pull(1).text == "" + discard mail.send(0, -2, "old match") + mail.beginTick(11) + doAssert mail.count(1) == 1 + mail.beginTick(0) + doAssert mail.count(1) == 0 + +echo "Testing BASIC mailbox strings, sender IDs, and seat isolation" +block: + let + mail = newMailboxes(2) + sender = newChatHost(0) + receiver = newChatHost(1) + sender.mailboxes = mail + receiver.mailboxes = mail + var + firstHost = initHost() + secondHost = initHost() + sender.addFunctions(firstHost) + receiver.addFunctions(secondHost) + var first = initRuntime(compile(""" +sent = sendChat(1, "Hello from BASIC.") +own$ = pullMailbox$() +""", firstHost), firstHost) + var second = initRuntime(compile(""" +message$ = pullMailbox$() +from = mailboxId() +sentAt = mailboxTick() +left = mailboxCount() +empty$ = pullMailbox$() +missing = mailboxId() +""", secondHost), secondHost) + sender.bindRuntime(first) + receiver.bindRuntime(second) + sender.beginTick(9) + receiver.beginTick(9) + discard first.run() + discard second.run() + doAssert first.getGlobal("sent") == 1 + doAssert first.getString(first.getGlobalValue("own$")) == "" + doAssert second.getString(second.getGlobalValue("message$")) == + "Hello from BASIC." + doAssert second.getGlobal("from") == 0 + doAssert second.getGlobal("sentAt") == 9 + doAssert second.getGlobal("left") == 0 + doAssert second.getGlobal("missing") == NoMailboxId + doAssert second.getString(second.getGlobalValue("empty$")) == "" diff --git a/tests/tests.nim b/tests/tests.nim index 88386a14..0ad97e4e 100644 --- a/tests/tests.nim +++ b/tests/tests.nim @@ -68,6 +68,8 @@ import test_lvd_maps, test_lvd_replays, test_lvd_sim, + test_mailboxes, + test_chats, test_metrics, test_stats, test_nav, From 66656a9609047a9a8db620da1f7b8b49388d4b6d Mon Sep 17 00:00:00 2001 From: treeform Date: Thu, 24 Sep 2026 11:04:07 -0700 Subject: [PATCH 2/6] Reclaim temporary strings between BASIC decisions --- docs/mailboxes.md | 2 ++ examples/call_to_adventure/bots.nim | 6 +++--- examples/gods_of_the_arena/bots.nim | 6 +++--- examples/light_vs_dark/bots.nim | 4 ++-- src/polyworld/scripts.nim | 31 +++++++++++++++++++++++++++++ tests/test_chats.nim | 2 +- tests/test_scripts.nim | 30 ++++++++++++++++++++++++++++ tests/tests.nim | 1 + 8 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 src/polyworld/scripts.nim create mode 100644 tests/test_scripts.nim diff --git a/docs/mailboxes.md b/docs/mailboxes.md index b8bac628..7f967f38 100644 --- a/docs/mailboxes.md +++ b/docs/mailboxes.md @@ -72,3 +72,5 @@ recipients can still accept a broadcast. Messages persist until pulled and are cleared on policy reload or a backward tick reset. Chat belongs to the live agent session. Replay actions preserve resulting gameplay, but this port does not store chat text in replays or add a graphical chat panel. +Temporary BASIC strings are reclaimed between decisions while global variables +and arrays retain their values. diff --git a/examples/call_to_adventure/bots.nim b/examples/call_to_adventure/bots.nim index 353bc3f6..2a0a34ff 100644 --- a/examples/call_to_adventure/bots.nim +++ b/examples/call_to_adventure/bots.nim @@ -6,8 +6,8 @@ import bassy, - polyworld/[chats, mailboxes, bodies, metrics, cli, controllers, pathing, - profiles], + polyworld/[scripts, chats, mailboxes, bodies, metrics, cli, controllers, + pathing, profiles], content, sim, replays @@ -270,7 +270,7 @@ proc runBotDecisions*(game: Game, slot: int32) {.measure.} = activeGame = game activeHeroSlot = slot let objective = game.objectiveTile(slot) - game.heroVms[slot].runtime.restart() + game.heroVms[slot].runtime.restartScript() try: if game.heroVms[slot].prepareDecision != nil: game.heroVms[slot].prepareDecision(game.world.tick) diff --git a/examples/gods_of_the_arena/bots.nim b/examples/gods_of_the_arena/bots.nim index 08f5a4fc..85a926ca 100644 --- a/examples/gods_of_the_arena/bots.nim +++ b/examples/gods_of_the_arena/bots.nim @@ -3,8 +3,8 @@ import bassy, fixxy, - polyworld/[chats, mailboxes, metrics, bodies, cli, controllers, pathing, - profiles, tapes], + polyworld/[scripts, chats, mailboxes, metrics, bodies, cli, controllers, + pathing, profiles, tapes], content, maps, motions, @@ -843,7 +843,7 @@ proc runHeroScript(game: Game, index: int) = vm = game.heroVms[index] if vm == nil or vm.failed: return - vm.runtime.restart() + vm.runtime.restartScript() try: if vm.prepareDecision != nil: vm.prepareDecision(game.world.tick) diff --git a/examples/light_vs_dark/bots.nim b/examples/light_vs_dark/bots.nim index 02d620a9..4aa1a0e4 100644 --- a/examples/light_vs_dark/bots.nim +++ b/examples/light_vs_dark/bots.nim @@ -12,7 +12,7 @@ import bassy, - polyworld/[chats, mailboxes, bodies, metrics, profiles], + polyworld/[scripts, chats, mailboxes, bodies, metrics, profiles], content, sim @@ -595,7 +595,7 @@ proc runDecision(game: Game, player: int32) = home = structure.origin break - game.brains[player].runtime.restart() + game.brains[player].runtime.restartScript() try: if game.brains[player].prepareDecision != nil: game.brains[player].prepareDecision(game.world.tick) diff --git a/src/polyworld/scripts.nim b/src/polyworld/scripts.nim new file mode 100644 index 00000000..dcf22fc1 --- /dev/null +++ b/src/polyworld/scripts.nim @@ -0,0 +1,31 @@ +import + std/importutils, + bassy, bassy/texts + +proc restartScript*(runtime: var Runtime) = + ## Reclaims temporary strings while preserving persistent script state. + privateAccess(Runtime) + privateAccess(Program) + if runtime.program.usesStrings: + # The pinned Bassy version only compacts strings on a full reset. + # Preserve all live roots with that compactor at decision boundaries. + let + globals = runtime.globals.len + cells = runtime.memory.len + var roots = newSeq[Value](globals + cells + runtime.hostData.len) + for i, value in runtime.globals: + roots[i] = value + for i, value in runtime.memory: + roots[globals + i] = value + for i, value in runtime.hostData: + roots[globals + cells + i] = value + runtime.strings.reset(roots) + for i in 0 ..< globals: + runtime.globals[i] = roots[i] + for i in 0 ..< cells: + runtime.memory[i] = roots[globals + i] + for i in 0 ..< runtime.hostData.len: + runtime.hostData[i] = roots[globals + cells + i] + for handle in runtime.stringLiterals.mitems: + handle = -1 + runtime.restart() diff --git a/tests/test_chats.nim b/tests/test_chats.nim index aa8b47d7..cfaec275 100644 --- a/tests/test_chats.nim +++ b/tests/test_chats.nim @@ -48,7 +48,7 @@ block: let message = game.mailboxes.pull(slot) doAssert (message.text == "team") == (game.mailboxes.teams[slot] == game.mailboxes.teams[0]) - for tick in 1 .. 2: + for tick in 1 .. 300: game.world.tick = int32(tick) when defined(mailboxCta): for slot in 0'i32 ..< PartySize: diff --git a/tests/test_scripts.nim b/tests/test_scripts.nim new file mode 100644 index 00000000..b23d8d79 --- /dev/null +++ b/tests/test_scripts.nim @@ -0,0 +1,30 @@ +import + bassy, + polyworld/scripts + +echo "Testing string reclamation preserves globals, arrays, and host data" +block: + var host = initHost() + discard host.addData("incoming$", "host value") + let program = compile(""" +dim saved$(2) +if turns = 0 then + saved$(0) = "persistent text" + saved$(1) = mid$(saved$(0), 2, 4) +end if +turns = turns + 1 +message$ = "turn " + str$(turns) + incoming$ +saved$(2) = message$ +""", host) + var runtime = initRuntime(program, host) + for i in 1 .. 1000: + runtime.restartScript() + discard runtime.run() + doAssert runtime.getGlobal("turns") == i + doAssert runtime.getStringArray("saved$", 0) == "persistent text" + doAssert runtime.getStringArray("saved$", 1) == "ersi" + doAssert runtime.getStringArray("saved$", 2) == + "turn " & $i & "host value" + doAssert runtime.getStringData("incoming$") == "host value" + doAssert runtime.stringCount < 32 + doAssert runtime.stringBytes < 256 diff --git a/tests/tests.nim b/tests/tests.nim index 0ad97e4e..8d6248f9 100644 --- a/tests/tests.nim +++ b/tests/tests.nim @@ -70,6 +70,7 @@ import test_lvd_sim, test_mailboxes, test_chats, + test_scripts, test_metrics, test_stats, test_nav, From 2a2dcb2090595fd9e52bafb8d9498e50cb4b0ae1 Mon Sep 17 00:00:00 2001 From: treeform Date: Thu, 24 Sep 2026 11:08:06 -0700 Subject: [PATCH 3/6] Test default mailboxes across all games --- .github/workflows/build.yml | 2 - docs/mailboxes.md | 3 ++ tests/test_chats.nim | 86 +++++++++++++++++++++---------------- 3 files changed, 51 insertions(+), 40 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e2c4e829..e27fe8aa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,8 +23,6 @@ jobs: nimby install "${{ github.event.repository.name }}/${{ github.event.repository.name }}.nimble" nimby sync "${{ github.event.repository.name }}/nimby.lock" - run: nim r tests/tests.nim - - run: nim r -d:headless -d:mailboxCta tests/test_chats.nim - - run: nim r -d:headless -d:mailboxLvd tests/test_chats.nim - run: nim r tests/test_gota_events.nim - run: nim r -d:replayEvents tests/test_gota_controls.nim - run: nim r -d:replayEvents tests/test_gota_camps.nim diff --git a/docs/mailboxes.md b/docs/mailboxes.md index 7f967f38..1baf2398 100644 --- a/docs/mailboxes.md +++ b/docs/mailboxes.md @@ -1,5 +1,8 @@ # Player mailboxes and chat +Mailboxes are always available to BASIC scripts in Gods of the Arena, +Call to Adventure, and Light vs Dark. No enable or disable flag is needed. + Every player owns one private FIFO queue. There is no shared global queue or team queue. Sending chooses which player queues receive their own copy: diff --git a/tests/test_chats.nim b/tests/test_chats.nim index cfaec275..e73e649c 100644 --- a/tests/test_chats.nim +++ b/tests/test_chats.nim @@ -2,13 +2,17 @@ import std/[os, strutils, tempfiles], bassy, polyworld/[cli, mailboxes] - -when defined(mailboxCta): - import ../examples/call_to_adventure/[bots, content, sim] -elif defined(mailboxLvd): - import ../examples/light_vs_dark/[bots, content, maps, sim] -else: - import ../examples/gods_of_the_arena/[bots, maps, replays, sim] +import ../examples/call_to_adventure/bots as ctaBots +import ../examples/call_to_adventure/content as ctaContent +import ../examples/call_to_adventure/sim as ctaSim +import ../examples/gods_of_the_arena/bots as gotaBots +import ../examples/gods_of_the_arena/maps as gotaMaps +import ../examples/gods_of_the_arena/replays as gotaReplays +import ../examples/gods_of_the_arena/sim as gotaSim +import ../examples/light_vs_dark/bots as lvdBots +import ../examples/light_vs_dark/content as lvdContent +import ../examples/light_vs_dark/maps as lvdMaps +import ../examples/light_vs_dark/sim as lvdSim const Program = """ sent = sendChat(mailboxSelf(), "private hello") @@ -16,33 +20,9 @@ message$ = pullMailbox$() from = mailboxId() """ -echo "Testing mailbox functions through the game's actual BASIC hosts and loaders" -block: - let - directory = createTempDir("polyworld-mailboxes-", "") - path = directory / "player.bas" - defer: - removeDir(directory) - writeFile(path, Program) - when defined(mailboxCta): - let game = newGame(2026) - elif defined(mailboxLvd): - let game = newGame(generateMap(DefaultSeed), 240) - else: - let game = newGame(generateMap(54), 240, 10, false, ReplayData(), - drafting = false) - - when defined(mailboxLvd): - game.loadBots([Program, Program]) - elif defined(mailboxCta): - game.loadBots([BotGroup(path: path, count: PartySize)]) - else: - game.loadBots([BotGroup(path: path, count: 10)]) +proc checkMailboxes[T](game: T, teamCount: int) = + ## Checks default chat routing and repeated reads through a game's hosts. doAssert game.mailboxes != nil - let teamCount = - when defined(mailboxCta): PartySize - elif defined(mailboxLvd): 1 - else: 5 doAssert game.mailboxes.send(0, TeamMailboxId, "team") == teamCount for slot in 0 ..< game.mailboxes.players: let message = game.mailboxes.pull(slot) @@ -50,12 +30,14 @@ block: (game.mailboxes.teams[slot] == game.mailboxes.teams[0]) for tick in 1 .. 300: game.world.tick = int32(tick) - when defined(mailboxCta): - for slot in 0'i32 ..< PartySize: - game.runBotDecisions(slot) + when T is ctaSim.Game: + for slot in 0'i32 ..< ctaContent.PartySize: + ctaBots.runBotDecisions(game, slot) + elif T is gotaSim.Game: + gotaBots.runBotDecisions(game) else: - game.runBotDecisions() - when defined(mailboxLvd): + lvdBots.runBotDecisions(game) + when T is lvdSim.Game: let vms = game.brains else: let vms = game.heroVms @@ -67,3 +49,31 @@ block: "private hello" let large = vm.runtime.putString(repeat('x', 1024)) doAssert vm.runtime.getString(large).len == 1024 + +echo "Testing default mailboxes through all three games' BASIC hosts" +block: + let + directory = createTempDir("polyworld-mailboxes-", "") + path = directory / "player.bas" + defer: + removeDir(directory) + writeFile(path, Program) + + let gota = gotaSim.newGame( + gotaMaps.generateMap(54), + 240, + 10, + false, + gotaReplays.ReplayData(), + drafting = false + ) + gotaBots.loadBots(gota, [BotGroup(path: path, count: 10)]) + gota.checkMailboxes(5) + + let cta = ctaSim.newGame(2026) + ctaBots.loadBots(cta, [BotGroup(path: path, count: ctaContent.PartySize)]) + cta.checkMailboxes(ctaContent.PartySize) + + let lvd = lvdSim.newGame(lvdMaps.generateMap(lvdContent.DefaultSeed), 240) + lvdBots.loadBots(lvd, [Program, Program]) + lvd.checkMailboxes(1) From a88921404a87b7f37124b32c7710198ae1490952 Mon Sep 17 00:00:00 2001 From: treeform Date: Thu, 24 Sep 2026 11:22:58 -0700 Subject: [PATCH 4/6] Preallocate mailbox buffers and script scratch --- .github/workflows/build.yml | 1 + docs/mailboxes.md | 28 +++-- examples/call_to_adventure/bots.nim | 7 +- examples/gods_of_the_arena/bots.nim | 7 +- examples/light_vs_dark/bots.nim | 7 +- src/polyworld/chats.nim | 38 ++++--- src/polyworld/mailboxes.nim | 154 ++++++++++++++++++++-------- src/polyworld/scripts.nim | 71 +++++++++++-- tests/test_chats.nim | 2 +- tests/test_mailbox_allocations.nim | 74 +++++++++++++ tests/test_mailboxes.nim | 45 ++++++-- tests/test_scripts.nim | 3 +- 12 files changed, 343 insertions(+), 94 deletions(-) create mode 100644 tests/test_mailbox_allocations.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e27fe8aa..a150cbdd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,7 @@ jobs: nimby install "${{ github.event.repository.name }}/${{ github.event.repository.name }}.nimble" nimby sync "${{ github.event.repository.name }}/nimby.lock" - run: nim r tests/tests.nim + - run: nim r -d:nimAllocStats tests/test_mailbox_allocations.nim - run: nim r tests/test_gota_events.nim - run: nim r -d:replayEvents tests/test_gota_controls.nim - run: nim r -d:replayEvents tests/test_gota_camps.nim diff --git a/docs/mailboxes.md b/docs/mailboxes.md index 1baf2398..bb38ac7f 100644 --- a/docs/mailboxes.md +++ b/docs/mailboxes.md @@ -28,10 +28,10 @@ After each successful pull, `mailboxId()` identifies that message: | Nonnegative | A DM from this player ID, not the recipient ID. | `mailboxSender()` also provides the actual sender for broadcasts. -`mailboxTick()` gives the send tick, `mailboxCount()` counts unread messages, -and `mailboxChannel$()` optionally returns `dm`, `team`, or `global`. +`mailboxTick()` gives the send tick and `mailboxCount()` counts unread messages. +Channel metadata uses numeric IDs exclusively. An empty pull clears the last envelope, making `mailboxId()` return -3, -`mailboxSender()` return -1, and `mailboxChannel$()` return an empty string. +`mailboxSender()` return -1, and `mailboxTick()` return zero. ```basic sendChat(-2, "Hello everyone.") @@ -65,10 +65,12 @@ to reject that copy. The game can consult its current world for proximity, hearing range, visibility, alive state, or other rules. This runs after the normal destination routing, so a range rule can narrow a global broadcast or refuse a distant DM without granting access to another player's queue. -Changing `mailboxes.teams` updates team routing for future sends. Rules must -not mutate or recursively send through the router while checking delivery. +Changing entries in the fixed `mailboxes.teams` array updates future team +routing. Rules must not mutate, retain, or recursively send through the router +while checking delivery. The rule's message reference is reused on the next +send. A custom rule must also avoid allocations to keep routing allocation-free. -Each queue holds at most 64 unread messages of up to 1024 UTF-8 bytes each. +Each queue holds at most 128 unread messages of up to 1024 UTF-8 bytes each. Empty, invalid UTF-8, oversized, and invalid-destination messages are refused. A full queue rejects new copies while retaining unread messages; other recipients can still accept a broadcast. Messages persist until pulled and @@ -77,3 +79,17 @@ live agent session. Replay actions preserve resulting gameplay, but this port does not store chat text in replays or add a graphical chat panel. Temporary BASIC strings are reclaimed between decisions while global variables and arrays retain their values. + +All queue storage is allocated when the roster is created. Each mailbox is a +reference object with a fixed array of 128 preallocated message references, +plus one reusable last-read envelope. Message text uses fixed 1024-byte arrays, +not Nim strings. Pulling swaps references, and clearing only resets counters. +Send, pull, overflow, and same-roster reset do not allocate or free heap memory. +Creating a different roster allocates new storage outside the tick loop. + +Nim callers receive a borrowed `MailMessage` reference, valid until that +player's next pull or a reset. `message.withText(bytes)` borrows the occupied +bytes within its block; `message.matches(text)` compares without allocating. +The BASIC bridge copies these bytes directly between the mailbox and the VM's +preallocated string arena. String compaction also uses scratch space reserved +when binding the VM. Allocation-counter tests cover these paths. diff --git a/examples/call_to_adventure/bots.nim b/examples/call_to_adventure/bots.nim index 2a0a34ff..58734575 100644 --- a/examples/call_to_adventure/bots.nim +++ b/examples/call_to_adventure/bots.nim @@ -6,7 +6,7 @@ import bassy, - polyworld/[scripts, chats, mailboxes, bodies, metrics, cli, controllers, + polyworld/[chats, mailboxes, bodies, metrics, cli, controllers, pathing, profiles], content, sim, @@ -94,7 +94,7 @@ proc issueHeroAction(action: ReplayAction): int32 = proc heroLimits(): Limits = ## Defines one isolated hero VM's source, memory, and decision budgets. result = defaultLimits() - result.maxStringBytes = 128 * 1024 + result.maxStringBytes = 256 * 1024 result.maxSourceBytes = 128 * 1024 result.maxCodeInstructions = 50_000 result.maxArrays = 32 @@ -270,10 +270,11 @@ proc runBotDecisions*(game: Game, slot: int32) {.measure.} = activeGame = game activeHeroSlot = slot let objective = game.objectiveTile(slot) - game.heroVms[slot].runtime.restartScript() try: if game.heroVms[slot].prepareDecision != nil: game.heroVms[slot].prepareDecision(game.world.tick) + else: + game.heroVms[slot].runtime.restart() game.heroVms[slot].runtime.setData(heroDataIds[DataSelfId], actor.id) game.heroVms[slot].runtime.setData( heroDataIds[DataSelfClass], diff --git a/examples/gods_of_the_arena/bots.nim b/examples/gods_of_the_arena/bots.nim index 85a926ca..477f19a5 100644 --- a/examples/gods_of_the_arena/bots.nim +++ b/examples/gods_of_the_arena/bots.nim @@ -3,7 +3,7 @@ import bassy, fixxy, - polyworld/[scripts, chats, mailboxes, metrics, bodies, cli, controllers, + polyworld/[chats, mailboxes, metrics, bodies, cli, controllers, pathing, profiles, tapes], content, maps, @@ -103,7 +103,7 @@ proc bindHeroData(program: Program) = proc heroVmLimits(): Limits = ## Returns independent structural and per-decision limits for a hero VM. result = defaultLimits() - result.maxStringBytes = 128 * 1024 + result.maxStringBytes = 256 * 1024 result.maxSourceBytes = 64 * 1024 result.maxCodeInstructions = 20_000 result.maxArrays = 32 @@ -843,10 +843,11 @@ proc runHeroScript(game: Game, index: int) = vm = game.heroVms[index] if vm == nil or vm.failed: return - vm.runtime.restartScript() try: if vm.prepareDecision != nil: vm.prepareDecision(game.world.tick) + else: + vm.runtime.restart() discard game.world.worldObjectCount(hero.id) vm.runtime.setData(heroDataIds[DataSelfId], hero.id) vm.runtime.setData(heroDataIds[DataSelfTeam], int32(hero.team.ord)) diff --git a/examples/light_vs_dark/bots.nim b/examples/light_vs_dark/bots.nim index 4aa1a0e4..1829060e 100644 --- a/examples/light_vs_dark/bots.nim +++ b/examples/light_vs_dark/bots.nim @@ -12,7 +12,7 @@ import bassy, - polyworld/[scripts, chats, mailboxes, bodies, metrics, profiles], + polyworld/[chats, mailboxes, bodies, metrics, profiles], content, sim @@ -243,7 +243,7 @@ proc overlordLimits*(): Limits = ## unit will exceed it and fail the script, which is the pressure that ## pushes authors onto `nearestEnemy` and friends. result = defaultLimits() - result.maxStringBytes = 128 * 1024 + result.maxStringBytes = 256 * 1024 result.maxSourceBytes = 256 * 1024 result.maxCodeInstructions = 100_000 result.maxArrays = 64 @@ -595,10 +595,11 @@ proc runDecision(game: Game, player: int32) = home = structure.origin break - game.brains[player].runtime.restartScript() try: if game.brains[player].prepareDecision != nil: game.brains[player].prepareDecision(game.world.tick) + else: + game.brains[player].runtime.restart() let economy = addr game.world.players[player] ids = overlordDataIds diff --git a/src/polyworld/chats.nim b/src/polyworld/chats.nim index 14138309..9b5dd3dc 100644 --- a/src/polyworld/chats.nim +++ b/src/polyworld/chats.nim @@ -1,22 +1,23 @@ import bassy, - mailboxes + mailboxes, scripts type ChatHost* = ref object runtime {.cursor.}: Runtime + scratch: ScriptScratch slot: int mailboxes*: Mailboxes ChatFunction = enum - SendChat, PullMailbox, MailboxSender, MailboxChannel, MailboxTick, + SendChat, PullMailbox, MailboxSender, MailboxTick, MailboxCount, MailboxSelf, MailboxPlayers, MailboxId const FunctionNames: array[ChatFunction, string] = [ - "sendChat", "pullMailbox$", "mailboxSender", "mailboxChannel$", + "sendChat", "pullMailbox$", "mailboxSender", "mailboxTick", "mailboxCount", "mailboxSelf", "mailboxPlayers", "mailboxId" ] - FunctionParameters: array[ChatFunction, int] = [2, 0, 0, 0, 0, 0, 0, 0, 0] + FunctionParameters: array[ChatFunction, int] = [2, 0, 0, 0, 0, 0, 0, 0] proc newChatHost*(slot: int, mailboxes: Mailboxes = nil): ChatHost = ## Binds a player's BASIC host to its own mailbox address. @@ -25,9 +26,12 @@ proc newChatHost*(slot: int, mailboxes: Mailboxes = nil): ChatHost = proc bindRuntime*(host: ChatHost, runtime: Runtime) = ## Borrows the runtime owning these callbacks without a reference cycle. host.runtime = runtime + host.scratch = newScriptScratch(runtime) proc beginTick*(host: ChatHost, tick: int32) = - ## Advances message timestamps without consuming unread mail. + ## Restarts BASIC using reserved scratch and advances message timestamps. + if host.runtime != nil: + host.runtime.restartScript(host.scratch) host.mailboxes.beginTick(tick) proc decisionCallback*(host: ChatHost): proc(tick: int32) = @@ -42,24 +46,24 @@ proc callback(host: ChatHost, kind: ChatFunction): NumericHostProc = ## Converts bounded BASIC strings and mailbox addresses. template output(value: string): Value = ## Stores returned message text in BASIC's bounded string pool. - host.runtime.putString(value) + host.runtime.putScriptText(value) case kind of SendChat: - result = host.mailboxes.send( - host.slot, - int(arguments[0].asInt()), - host.runtime.getString(arguments[1]) - ) + host.runtime.withScriptText(arguments[1], text): + result = host.mailboxes.send(host.slot, int(arguments[0].asInt()), text) of PullMailbox: - result = output(host.mailboxes.pull(host.slot).text) + let message = host.mailboxes.pull(host.slot) + if message == nil: + result = output("") + else: + message.withText(text): + result = host.runtime.putScriptText(text) of MailboxSender: let last = host.mailboxes.last(host.slot) - result = int32(if last.text.len == 0: -1 else: last.sender) - of MailboxChannel: - let last = host.mailboxes.last(host.slot) - result = output(if last.text.len == 0: "" else: last.channel.channelName()) + result = int32(if last.len == 0: -1 else: last.sender) of MailboxTick: - result = host.mailboxes.last(host.slot).tick + let last = host.mailboxes.last(host.slot) + result = if last.len == 0: 0'i32 else: last.tick of MailboxCount: result = host.mailboxes.count(host.slot) of MailboxSelf: diff --git a/src/polyworld/mailboxes.nim b/src/polyworld/mailboxes.nim index cc015175..1c26171e 100644 --- a/src/polyworld/mailboxes.nim +++ b/src/polyworld/mailboxes.nim @@ -1,8 +1,6 @@ -import std/unicode - const MaxMailboxPlayers* = 64 - MaxMailboxMessages* = 64 + MaxMailboxMessages* = 128 MaxChatBytes* = 1024 GlobalMailboxId* = -2 TeamMailboxId* = -1 @@ -12,38 +10,50 @@ type MailboxError* = object of CatchableError MailChannel* = enum DirectMailbox, TeamMailbox, GlobalMailbox - MailMessage* = object + MailMessage* = ref object sender*, target*: int tick*: int32 channel*: MailChannel - text*: string + length: int + bytes: array[MaxChatBytes, char] MailRule* = proc(message: MailMessage, recipient: int): bool {.closure.} - Mailbox = object + Mailbox = ref object messages: array[MaxMailboxMessages, MailMessage] first, count: int last: MailMessage Mailboxes* = ref object - teams*: seq[int32] + teams*: array[MaxMailboxPlayers, int32] rule*: MailRule - boxes: seq[Mailbox] + boxes: array[MaxMailboxPlayers, Mailbox] + draft: MailMessage + playerCount: int tick: int32 proc newMailboxes*(players: int): Mailboxes = ## Creates private queues with an unrestricted common team by default. if players < 1 or players > MaxMailboxPlayers: raise newException(MailboxError, "Mailbox player count must be 1 .. 64") - Mailboxes( - teams: newSeq[int32](players), boxes: newSeq[Mailbox](players), tick: -1 - ) + result = Mailboxes(playerCount: players, draft: MailMessage(), tick: -1) + for i in 0 ..< players: + result.boxes[i] = Mailbox(last: MailMessage()) + for slot in 0 ..< MaxMailboxMessages: + result.boxes[i].messages[slot] = MailMessage() proc players*(mailboxes: Mailboxes): int = ## Reports the number of zero-based player addresses. - if mailboxes == nil: 0 else: mailboxes.boxes.len + if mailboxes == nil: 0 else: mailboxes.playerCount proc clear*(mailboxes: Mailboxes) = - ## Releases unread and last-read messages without changing game rules. - for box in mailboxes.boxes.mitems: - box = Mailbox() + ## Empties queues without allocating or releasing their backing storage. + if mailboxes == nil: + return + for i in 0 ..< mailboxes.players: + let box = mailboxes.boxes[i] + box.first = 0 + box.count = 0 + box.last.length = 0 + box.last.tick = 0 + mailboxes.draft.length = 0 proc reset*(mailboxes: var Mailboxes, players: int) = ## Starts a new roster while retaining rules for an unchanged game size. @@ -61,28 +71,93 @@ proc beginTick*(mailboxes: Mailboxes, tick: int32) = mailboxes.clear() mailboxes.tick = tick -proc channelName*(channel: MailChannel): string = - ## Returns the BASIC channel name associated with an envelope. - case channel - of DirectMailbox: "dm" - of TeamMailbox: "team" - of GlobalMailbox: "global" +proc len*(message: MailMessage): int {.raises: [].} = + ## Returns the occupied byte count, including zero for an empty pull. + if message == nil: 0 else: message.length + +template withText*(message: MailMessage, text, body: untyped) = + ## Borrows bytes until the next pull, clear, or roster reset. + block: + let envelope = message + assert envelope != nil + template text: untyped = + envelope.bytes.toOpenArray(0, envelope.length - 1) + body + +proc matches*(message: MailMessage, text: openArray[char]): bool = + ## Compares a message without constructing a temporary string. + if message.len != text.len: + return false + for i in 0 ..< text.len: + if message.bytes[i] != text[i]: + return false + true proc id*(message: MailMessage): int = ## Returns a broadcast address or the sender of a direct message. - if message.text.len == 0: + if message.len == 0: return NoMailboxId case message.channel of DirectMailbox: message.sender of TeamMailbox: TeamMailboxId of GlobalMailbox: GlobalMailboxId +proc validText(text: openArray[char]): bool = + ## Rejects malformed, overlong, surrogate, and out-of-range UTF-8 sequences. + var i = 0 + while i < text.len: + let first = ord(text[i]) + var + extra = 0 + low = 0x80 + high = 0xbf + case first + of 0 .. 0x7f: + inc i + continue + of 0xc2 .. 0xdf: + extra = 1 + of 0xe0 .. 0xef: + extra = 2 + if first == 0xe0: + low = 0xa0 + elif first == 0xed: + high = 0x9f + of 0xf0 .. 0xf4: + extra = 3 + if first == 0xf0: + low = 0x90 + elif first == 0xf4: + high = 0x8f + else: + return false + if extra >= text.len - i or ord(text[i + 1]) notin low .. high: + return false + for j in 2 .. extra: + if ord(text[i + j]) notin 0x80 .. 0xbf: + return false + i += extra + 1 + true + +proc write( + message: MailMessage, sender, target: int, tick: int32, + channel: MailChannel, text: openArray[char] +) = + ## Copies payload bytes into an existing envelope without replacing it. + message.sender = sender + message.target = target + message.tick = tick + message.channel = channel + message.length = text.len + for i in 0 ..< text.len: + message.bytes[i] = text[i] + proc send*( - mailboxes: Mailboxes, sender, target: int, text: string + mailboxes: Mailboxes, sender, target: int, text: openArray[char] ): int32 = ## Fans out a message through the game's audience and delivery rules. if mailboxes == nil or sender < 0 or sender >= mailboxes.players or - text.len == 0 or text.len > MaxChatBytes or text.validateUtf8() >= 0: + text.len == 0 or text.len > MaxChatBytes or not validText(text): return 0 if target < GlobalMailboxId or target >= mailboxes.players: return 0 @@ -91,27 +166,25 @@ proc send*( of GlobalMailboxId: GlobalMailbox of TeamMailboxId: TeamMailbox else: DirectMailbox - let message = MailMessage( - sender: sender, - target: target, - tick: mailboxes.tick, channel: channel, text: text - ) - for index, box in mailboxes.boxes.mpairs: - let recipient = index + mailboxes.draft.write(sender, target, mailboxes.tick, channel, text) + for recipient in 0 ..< mailboxes.players: + let box = mailboxes.boxes[recipient] case channel of DirectMailbox: if recipient != target: continue of TeamMailbox: - if mailboxes.teams[index] != mailboxes.teams[sender]: + if mailboxes.teams[recipient] != mailboxes.teams[sender]: continue of GlobalMailbox: discard if box.count == MaxMailboxMessages: continue - if mailboxes.rule != nil and not mailboxes.rule(message, recipient): + if mailboxes.rule != nil and not mailboxes.rule(mailboxes.draft, recipient): continue - box.messages[(box.first + box.count) mod MaxMailboxMessages] = message + box.messages[(box.first + box.count) mod MaxMailboxMessages].write( + sender, target, mailboxes.tick, channel, text + ) inc box.count inc result @@ -121,15 +194,16 @@ proc count*(mailboxes: Mailboxes, recipient: int): int32 = result = int32(mailboxes.boxes[recipient].count) proc pull*(mailboxes: Mailboxes, recipient: int): MailMessage = - ## Pops one FIFO message, or returns an empty envelope when drained. + ## Borrows the popped envelope until this player's next pull or reset. if mailboxes == nil or recipient notin 0 ..< mailboxes.players: return - let box = addr mailboxes.boxes[recipient] - box.last = MailMessage() + let box = mailboxes.boxes[recipient] + box.last.length = 0 + box.last.tick = 0 if box.count == 0: - return - result = move(box.messages[box.first]) - box.last = result + return box.last + swap(box.last, box.messages[box.first]) + result = box.last box.first = (box.first + 1) mod MaxMailboxMessages dec box.count diff --git a/src/polyworld/scripts.nim b/src/polyworld/scripts.nim index dcf22fc1..c0a23b27 100644 --- a/src/polyworld/scripts.nim +++ b/src/polyworld/scripts.nim @@ -2,7 +2,20 @@ import std/importutils, bassy, bassy/texts -proc restartScript*(runtime: var Runtime) = +type ScriptScratch* = ref object + roots: seq[Value] + +proc newScriptScratch*(runtime: Runtime): ScriptScratch = + ## Allocates string-compaction roots once when binding a runtime. + privateAccess(Runtime) + privateAccess(Program) + result = ScriptScratch() + if runtime.program.usesStrings: + result.roots.setLen( + runtime.globals.len + runtime.memory.len + runtime.hostData.len + ) + +proc restartScript*(runtime: var Runtime, scratch: ScriptScratch) = ## Reclaims temporary strings while preserving persistent script state. privateAccess(Runtime) privateAccess(Program) @@ -12,20 +25,60 @@ proc restartScript*(runtime: var Runtime) = let globals = runtime.globals.len cells = runtime.memory.len - var roots = newSeq[Value](globals + cells + runtime.hostData.len) + assert scratch.roots.len == globals + cells + runtime.hostData.len for i, value in runtime.globals: - roots[i] = value + scratch.roots[i] = value for i, value in runtime.memory: - roots[globals + i] = value + scratch.roots[globals + i] = value for i, value in runtime.hostData: - roots[globals + cells + i] = value - runtime.strings.reset(roots) + scratch.roots[globals + cells + i] = value + runtime.strings.reset(scratch.roots) for i in 0 ..< globals: - runtime.globals[i] = roots[i] + runtime.globals[i] = scratch.roots[i] for i in 0 ..< cells: - runtime.memory[i] = roots[globals + i] + runtime.memory[i] = scratch.roots[globals + i] for i in 0 ..< runtime.hostData.len: - runtime.hostData[i] = roots[globals + cells + i] + runtime.hostData[i] = scratch.roots[globals + cells + i] for handle in runtime.stringLiterals.mitems: handle = -1 runtime.restart() + +template withScriptText*( + runtime: Runtime, value: Value, text, body: untyped +) = + ## Borrows validated BASIC bytes for the duration of a host callback. + block: + privateAccess(Runtime) + privateAccess(TextStorage) + let + owner = runtime + input = value + length = owner.strings.length(input) + privateAccess(typeof(owner.strings.spans[0])) + let start = int(owner.strings.spans[int(input.stringHandle)].start) + template text: untyped = + owner.strings.arena.toOpenArray(start, start + length - 1) + body + +proc putScriptText*(runtime: var Runtime, text: openArray[char]): Value = + ## Copies bytes directly into BASIC's preallocated string arena. + privateAccess(Runtime) + privateAccess(TextStorage) + privateAccess(typeof(runtime.strings.spans[0])) + if text.len == 0: + return runtime.strings.empty + if runtime.strings.owner == 0: + raise newException(BasicError, "BASIC program has no string storage") + if text.len > runtime.strings.maxLength: + raise newException(BasicError, "BASIC string length limit exceeded") + if runtime.strings.spans.len >= runtime.strings.maxCount: + raise newException(BasicError, "BASIC string count limit exceeded") + if text.len > runtime.strings.maxBytes - runtime.strings.arena.len: + raise newException(BasicError, "BASIC string byte limit exceeded") + let handle = runtime.strings.spans.len + runtime.strings.spans.setLen(handle + 1) + runtime.strings.spans[handle].start = int32(runtime.strings.arena.len) + runtime.strings.spans[handle].length = int32(text.len) + for character in text: + runtime.strings.arena.add character + stringValue(runtime.strings.owner, int32(handle)) diff --git a/tests/test_chats.nim b/tests/test_chats.nim index e73e649c..b99e1f90 100644 --- a/tests/test_chats.nim +++ b/tests/test_chats.nim @@ -26,7 +26,7 @@ proc checkMailboxes[T](game: T, teamCount: int) = doAssert game.mailboxes.send(0, TeamMailboxId, "team") == teamCount for slot in 0 ..< game.mailboxes.players: let message = game.mailboxes.pull(slot) - doAssert (message.text == "team") == + doAssert (message.matches("team")) == (game.mailboxes.teams[slot] == game.mailboxes.teams[0]) for tick in 1 .. 300: game.world.tick = int32(tick) diff --git a/tests/test_mailbox_allocations.nim b/tests/test_mailbox_allocations.nim new file mode 100644 index 00000000..8280b282 --- /dev/null +++ b/tests/test_mailbox_allocations.nim @@ -0,0 +1,74 @@ +import + std/strutils, + bassy, + polyworld/[chats, mailboxes] + +when not defined(nimAllocStats): + {.error: "Run this test with -d:nimAllocStats to measure allocations.".} + +echo "Testing mailbox rings allocate only during initialization" +block: + var mail = newMailboxes(4) + let payload = repeat('x', MaxChatBytes) + mail.rule = proc(message: MailMessage, recipient: int): bool = + ## Applies a deterministic routing rule without allocating. + message.sender >= 0 and recipient >= 0 + let before = getAllocStats() + for round in 0 ..< 200: + mail.beginTick(int32(round)) + for slot in 0 ..< MaxMailboxMessages: + doAssert mail.send(0, GlobalMailboxId, payload) == 4 + doAssert mail.send(0, TeamMailboxId, "overflow") == 0 + doAssert mail.pull(0).matches(payload) + doAssert mail.send(1, 0, payload) == 1 + for player in 0 ..< 4: + while mail.count(player) > 0: + doAssert mail.pull(player).matches(payload) + doAssert mail.pull(player).len == 0 + doAssert mail.send(0, TeamMailboxId, "team") == 4 + mail.clear() + doAssert mail.send(0, 1, "reset") == 1 + mail.beginTick(-1) + doAssert mail.count(1) == 0 + mail.reset(4) + let after = getAllocStats() + doAssert after == before, $(after - before) + +echo "Testing BASIC send, pull, and decision restart allocate no heap memory" +block: + let + mail = newMailboxes(1) + chat = newChatHost(0, mail) + payload = repeat('x', MaxChatBytes) + var + host = initHost() + limits = defaultLimits() + chat.addFunctions(host) + limits.maxStringBytes = 256 * 1024 + limits.maxWorkUnits = 300_000 + let program = compile(""" +sent = 0 +for i = 1 to 128 + sent = sent + sendChat(mailboxSelf(), payload$) +next i +overflow = sendChat(-2, payload$) +received = 0 +message$ = pullMailbox$() +while message$ <> "" + received = received + 1 + sender = mailboxId() + message$ = pullMailbox$() +wend +""", host, limits) + var runtime = initRuntime(program, host, limits) + chat.bindRuntime(runtime) + runtime.setGlobal("payload$", payload) + let before = getAllocStats() + for tick in 0 ..< 1000: + chat.beginTick(int32(tick)) + discard runtime.run() + let after = getAllocStats() + doAssert after == before, $(after - before) + doAssert runtime.getGlobal("sent") == 128 + doAssert runtime.getGlobal("overflow") == 0 + doAssert runtime.getGlobal("received") == 128 diff --git a/tests/test_mailboxes.nim b/tests/test_mailboxes.nim index 22b9ae94..f19e7efa 100644 --- a/tests/test_mailboxes.nim +++ b/tests/test_mailboxes.nim @@ -7,22 +7,22 @@ echo "Testing private queues, broadcasts, DMs, and received message IDs" block: let mail = newMailboxes(3) mail.beginTick(12) - doAssert mail.pull(0).text == "" + doAssert mail.pull(0).matches("") doAssert mail.last(0).id == NoMailboxId doAssert mail.send(0, -2, "hello") == 3 doAssert mail.send(1, 0, "private") == 1 - doAssert mail.pull(0).text == "hello" + doAssert mail.pull(0).matches("hello") doAssert mail.last(0).id == -2 doAssert mail.last(0).sender == 0 - doAssert mail.pull(0).text == "private" + doAssert mail.pull(0).matches("private") doAssert mail.last(0).id == 1 doAssert mail.last(0).sender == 1 doAssert mail.last(0).target == 0 doAssert mail.last(0).tick == 12 - doAssert mail.pull(0).text == "" + doAssert mail.pull(0).matches("") doAssert mail.last(0).id == NoMailboxId - doAssert mail.pull(1).text == "hello" - doAssert mail.pull(2).text == "hello" + doAssert mail.pull(1).matches("hello") + doAssert mail.pull(2).matches("hello") doAssert mail.count(1) == 0 and mail.count(2) == 0 echo "Testing team membership and per-recipient game rules" @@ -31,7 +31,7 @@ block: doAssert mail.send(0, -1, "default team") == 3 doAssert mail.pull(2).id == -1 mail.clear() - mail.teams = @[0'i32, 0'i32, 1'i32] + mail.teams[2] = 1 doAssert mail.send(0, -1, "allies") == 2 doAssert mail.count(2) == 0 mail.clear() @@ -43,7 +43,7 @@ block: doAssert mail.send(0, 2, "too far") == 0 positions[2] = 3 doAssert mail.send(0, 2, "now near") == 1 - doAssert mail.pull(2).text == "now near" + doAssert mail.pull(2).matches("now near") echo "Testing mailbox memory bounds, wraparound, and reset" block: @@ -54,21 +54,44 @@ block: doAssert mail.send(0, -3, "invalid channel") == 0 doAssert mail.send(0, 1, repeat('x', MaxChatBytes + 1)) == 0 doAssert mail.send(0, 1, "\xff") == 0 + for invalid in ["\x80", "\xc0\x80", "\xc2", "\xe0\x80\x80", + "\xed\xa0\x80", "\xf0\x80\x80\x80", "\xf4\x90\x80\x80"]: + doAssert mail.send(0, 1, invalid) == 0 + let unicode = repeat('x', MaxChatBytes - 4) & "\xf0\x9f\x92\xa1" + doAssert mail.send(0, 1, unicode) == 1 + doAssert mail.pull(1).matches(unicode) for i in 0 ..< MaxMailboxMessages: doAssert mail.send(0, 1, $i) == 1 doAssert mail.send(0, 1, "overflow") == 0 for i in 0 ..< 10: - doAssert mail.pull(1).text == $i + doAssert mail.pull(1).matches($i) doAssert mail.send(0, 1, $(i + MaxMailboxMessages)) == 1 for i in 10 ..< MaxMailboxMessages + 10: - doAssert mail.pull(1).text == $i - doAssert mail.pull(1).text == "" + doAssert mail.pull(1).matches($i) + doAssert mail.pull(1).matches("") discard mail.send(0, -2, "old match") mail.beginTick(11) doAssert mail.count(1) == 1 mail.beginTick(0) doAssert mail.count(1) == 0 +echo "Testing borrowed envelopes survive sends and full-queue broadcasts" +block: + let mail = newMailboxes(2) + for i in 0 ..< MaxMailboxMessages: + doAssert mail.send(0, 0, "queued") == 1 + doAssert mail.send(0, GlobalMailboxId, "partial") == 1 + let message = mail.pull(0) + doAssert mail.send(1, 0, "replacement") == 1 + doAssert message.matches("queued") + doAssert mail.last(0) == message + doAssert mail.pull(1).matches("partial") + for i in 1 ..< MaxMailboxMessages: + doAssert mail.pull(0).matches("queued") + doAssert mail.pull(0).matches("replacement") + mail.clear() + doAssert mail.last(0).id == NoMailboxId + echo "Testing BASIC mailbox strings, sender IDs, and seat isolation" block: let diff --git a/tests/test_scripts.nim b/tests/test_scripts.nim index b23d8d79..95a0ff54 100644 --- a/tests/test_scripts.nim +++ b/tests/test_scripts.nim @@ -17,8 +17,9 @@ message$ = "turn " + str$(turns) + incoming$ saved$(2) = message$ """, host) var runtime = initRuntime(program, host) + let scratch = newScriptScratch(runtime) for i in 1 .. 1000: - runtime.restartScript() + runtime.restartScript(scratch) discard runtime.run() doAssert runtime.getGlobal("turns") == i doAssert runtime.getStringArray("saved$", 0) == "persistent text" From 9481dd47b9572b7057d53d5de41aa339152eb37b Mon Sep 17 00:00:00 2001 From: treeform Date: Thu, 24 Sep 2026 11:48:29 -0700 Subject: [PATCH 5/6] Simplify inboxes and move routing into games --- coworld/dependencies.lock | 2 +- docs/mailboxes.md | 112 +++++-------- examples/call_to_adventure/bots.nim | 74 +++++++-- examples/call_to_adventure/sim.nim | 3 +- examples/gods_of_the_arena/bots.nim | 78 +++++++-- examples/gods_of_the_arena/sim.nim | 3 +- examples/light_vs_dark/bots.nim | 77 +++++++-- examples/light_vs_dark/sim.nim | 3 +- examples/mailboxes/chat.bas | 2 +- nimby.lock | 2 +- src/polyworld/chats.nim | 84 ---------- src/polyworld/mailboxes.nim | 237 ++++------------------------ src/polyworld/scripts.nim | 84 ---------- tests/test_chats.nim | 59 ++++++- tests/test_gota_base.nim | 4 + tests/test_mailbox_allocations.nim | 76 ++------- tests/test_mailboxes.nim | 146 +++-------------- tests/test_scripts.nim | 31 ---- tests/tests.nim | 1 - 19 files changed, 345 insertions(+), 733 deletions(-) delete mode 100644 src/polyworld/chats.nim delete mode 100644 src/polyworld/scripts.nim delete mode 100644 tests/test_scripts.nim diff --git a/coworld/dependencies.lock b/coworld/dependencies.lock index 311ecd0f..95e9cb58 100644 --- a/coworld/dependencies.lock +++ b/coworld/dependencies.lock @@ -1,4 +1,4 @@ -bassy 0.1.0 https://github.com/treeform/bassy b25e0efef3fec0bd86ed3154659c0762a7158bd3 +bassy 0.1.0 https://github.com/treeform/bassy 669a7c4b94e3d5b0a38dc9557608a7e58c2a764d fixxy 0.1.0 https://github.com/treeform/fixxy 05e5446dffb70093056cebb0c57721a60deaf52a silky 0.2.0 https://github.com/treeform/silky fb9b13910edd66cf1751056784c2f7d2932a59fc pixie 6.1.0 https://github.com/treeform/pixie 87cecced5c4c6f311c658a5f3ca0c9b43edb6aa7 diff --git a/docs/mailboxes.md b/docs/mailboxes.md index bb38ac7f..9090c220 100644 --- a/docs/mailboxes.md +++ b/docs/mailboxes.md @@ -1,95 +1,55 @@ -# Player mailboxes and chat +# Player mailboxes -Mailboxes are always available to BASIC scripts in Gods of the Arena, -Call to Adventure, and Light vs Dark. No enable or disable flag is needed. +Each player has one inbox with 100 message slots. A message is an integer ID +and a string, limited to 1024 bytes. Sending to a full inbox does nothing; +unread messages remain until the player pulls them. -Every player owns one private FIFO queue. There is no shared global queue or -team queue. Sending chooses which player queues receive their own copy: +Each game defines `sendChat` and its BASIC callbacks in its own `bots.nim`. +The examples use these routing rules: -| `sendChat(id, text$)` destination | Routing | +| `sendChat(id, text$)` target | Recipients | | --- | --- | -| `-2` | All players, including the sender. | -| `-1` | The sender's teammates, including the sender. | -| `0` through `mailboxPlayers()-1` | Only that player. | +| `-2` | Everyone, including the sender. | +| `-1` | Teammates, including the sender. | +| Nonnegative | The player with that zero-based roster ID. | -Player IDs are zero-based roster slots, not game entity IDs. GotA uses 0 to 9, -CTA uses 0 to 3, and Light vs Dark uses 0 to 1. `mailboxSelf()` returns the -calling player's ID. The send function returns the number of player queues -that accepted the message, or zero if none did. +GotA uses the heroes' red/blue teams. CTA treats the party as one team. +Light vs Dark treats each player as their own team. The game can change its +routing loop to add range, visibility, or other rules. There is no shared +routing policy or routing callback framework. -`pullMailbox$()` removes the oldest message from the caller's own queue and -returns its text. It returns an empty string immediately if there is no mail. -After each successful pull, `mailboxId()` identifies that message: +`sendChat` returns the number of inboxes that accepted a copy. Empty or +oversized messages and invalid destinations return zero. A broadcast can +reach some players even when another player's inbox is full. -| Received `mailboxId()` | Meaning | -| --- | --- | -| `-2` | A global broadcast. | -| `-1` | A team broadcast. | -| Nonnegative | A DM from this player ID, not the recipient ID. | +`pullMailbox$()` consumes the oldest message, or returns an empty string if +there is none. `mailboxId()` identifies the last message pulled: `-2` for +global, `-1` for team, or the sender's player ID for a DM. An empty pull sets +that ID to `-3`. There is no separate sender, channel name, or timestamp. -`mailboxSender()` also provides the actual sender for broadcasts. -`mailboxTick()` gives the send tick and `mailboxCount()` counts unread messages. -Channel metadata uses numeric IDs exclusively. -An empty pull clears the last envelope, making `mailboxId()` return -3, -`mailboxSender()` return -1, and `mailboxTick()` return zero. +`mailboxCount()` counts unread messages, `mailboxSelf()` returns the caller's +roster ID, and `mailboxPlayers()` gives the roster size. GotA uses IDs 0 to 9, +CTA uses 0 to 3, and Light vs Dark uses 0 to 1. ```basic sendChat(-2, "Hello everyone.") sendChat(-1, "Meet at the checkpoint.") sendChat(0, "A direct message to player zero.") - message$ = pullMailbox$() while message$ <> "" - from = mailboxId() - print from, message$ + print mailboxId(), message$ message$ = pullMailbox$() wend ``` -Delivery is immediate in send order. Scripts may send and poll whenever they -run, including multiple pulls during one decision. A later script in a tick -can read an earlier script's message in that tick. A player whose turn has -already ended reads it on its next decision. -`examples/mailboxes/chat.bas` demonstrates draining a queue and all three -destination types. - -`mailboxes.nim` owns the queues and fan-out rules. Each game's `Game.mailboxes` -owns the shared router, and each BASIC host is bound to its own player ID. -GotA supplies its red/blue teams, CTA puts the whole party on one team, and -each opposing Light vs Dark player has its own team. The generic router starts -with everyone on one team and no distance or visibility restrictions. - -A game can set `Game.mailboxes.rule`, a callback receiving the message and -each candidate recipient's zero-based ID. It returns true to deliver or false -to reject that copy. The game can consult its current world for proximity, -hearing range, visibility, alive state, or other rules. This runs after the -normal destination routing, so a range rule can narrow a global broadcast -or refuse a distant DM without granting access to another player's queue. -Changing entries in the fixed `mailboxes.teams` array updates future team -routing. Rules must not mutate, retain, or recursively send through the router -while checking delivery. The rule's message reference is reused on the next -send. A custom rule must also avoid allocations to keep routing allocation-free. - -Each queue holds at most 128 unread messages of up to 1024 UTF-8 bytes each. -Empty, invalid UTF-8, oversized, and invalid-destination messages are refused. -A full queue rejects new copies while retaining unread messages; other -recipients can still accept a broadcast. Messages persist until pulled and -are cleared on policy reload or a backward tick reset. Chat belongs to the -live agent session. Replay actions preserve resulting gameplay, but this port -does not store chat text in replays or add a graphical chat panel. -Temporary BASIC strings are reclaimed between decisions while global variables -and arrays retain their values. - -All queue storage is allocated when the roster is created. Each mailbox is a -reference object with a fixed array of 128 preallocated message references, -plus one reusable last-read envelope. Message text uses fixed 1024-byte arrays, -not Nim strings. Pulling swaps references, and clearing only resets counters. -Send, pull, overflow, and same-roster reset do not allocate or free heap memory. -Creating a different roster allocates new storage outside the tick loop. - -Nim callers receive a borrowed `MailMessage` reference, valid until that -player's next pull or a reset. `message.withText(bytes)` borrows the occupied -bytes within its block; `message.matches(text)` compares without allocating. -The BASIC bridge copies these bytes directly between the mailbox and the VM's -preallocated string arena. String compaction also uses scratch space reserved -when binding the VM. Allocation-counter tests cover these paths. +Delivery follows script execution order. A later player can read a message +in the same tick. Players can send and pull multiple times per decision, +within their normal BASIC instruction and string limits. Inboxes persist +across decisions and are replaced when policies are loaded for a new run. +Chat text is not recorded in replays and has no graphical chat panel. + +`mailboxes.nim` is only a bounded inbox: an array of IDs, an array of reserved +strings, and read/count fields. It has no BASIC dependency. Each game owns +one inbox per player and copies message text through Bassy's public API. +Bassy owns its string storage and reclaims temporary strings on `restart()`. +Polyworld does not inspect or manage BASIC's private storage. diff --git a/examples/call_to_adventure/bots.nim b/examples/call_to_adventure/bots.nim index 58734575..7c83711a 100644 --- a/examples/call_to_adventure/bots.nim +++ b/examples/call_to_adventure/bots.nim @@ -6,7 +6,7 @@ import bassy, - polyworld/[chats, mailboxes, bodies, metrics, cli, controllers, + polyworld/[mailboxes, bodies, metrics, cli, controllers, pathing, profiles], content, sim, @@ -113,11 +113,63 @@ proc heroLimits(): Limits = result.maxPrintBytes = 4 * 1024 result.maxPrintEvents = 256 -proc buildHeroHost(heroId: int32, chat: ChatHost = nil): Host = +proc sendChat*( + game: Game, sender, target: int, text: openArray[char] +): int32 = + ## Routes chat according to this game's player and team rules. + if sender notin 0 ..< game.inboxes.len or + target < -2 or target >= game.inboxes.len: + return 0 + let id = int32(if target < 0: target else: sender) + for recipient in 0 ..< game.inboxes.len: + case target + of -2: + discard + of -1: + discard + else: + if recipient != target: + continue + if game.inboxes[recipient].push(id, text): + inc result + +proc buildHeroHost(heroId: int32): Host = ## Builds the world-query and high-level action API for one hero. result = initHost() - let services = if chat == nil: newChatHost(0) else: chat - services.addFunctions(result) + let sendChatProc: NumericHostProc = proc(args: openArray[Value]): Value = + ## Sends script text through the game's routing rules. + let player = int(heroId - 100) + activeGame.heroVms[player].runtime.withString(args[1], text): + result = activeGame.sendChat(player, int(args[0].asInt), text) + let pullMailboxProc: NumericHostProc = proc(args: openArray[Value]): Value = + ## Copies the oldest message into BASIC and consumes it on success. + let + player = int(heroId - 100) + inbox = activeGame.inboxes[player] + var runtime = activeGame.heroVms[player].runtime + if inbox.count == 0: + result = runtime.putString("") + else: + result = runtime.putString(inbox.messages[inbox.first]) + discard inbox.pop() + let mailboxIdProc: HostProc = proc(args: openArray[int32]): int32 = + ## Returns the channel or DM sender of the last pulled message. + activeGame.inboxes[int(heroId - 100)].lastId + let mailboxCountProc: HostProc = proc(args: openArray[int32]): int32 = + ## Counts this player's unread messages. + int32(activeGame.inboxes[int(heroId - 100)].count) + let mailboxSelfProc: HostProc = proc(args: openArray[int32]): int32 = + ## Returns this player's zero-based mailbox address. + int32(int(heroId - 100)) + let mailboxPlayersProc: HostProc = proc(args: openArray[int32]): int32 = + ## Returns the number of player mailboxes in this game. + int32(activeGame.inboxes.len) + discard result.addFunction("sendChat", 2, sendChatProc, 256) + discard result.addFunction("pullMailbox$", 0, pullMailboxProc, 256) + discard result.addFunction("mailboxId", 0, mailboxIdProc, 4) + discard result.addFunction("mailboxCount", 0, mailboxCountProc, 4) + discard result.addFunction("mailboxSelf", 0, mailboxSelfProc, 4) + discard result.addFunction("mailboxPlayers", 0, mailboxPlayersProc, 4) for name in HeroDataNames: discard result.addData(name) @@ -229,13 +281,12 @@ proc loadBots*( schema = buildHeroHost(100) kinds = controllerKinds(PartySize, playerSlot) sources = groups.expandBotSources(kinds) - game.mailboxes.reset(PartySize) + for inbox in game.inboxes.mitems: + inbox = newMailbox() var bound = false for slot in 0 ..< PartySize: if kinds[slot] == PlayerController: continue - let chat = newChatHost(slot) - chat.mailboxes = game.mailboxes let source = sources[slot] let program = when defined(coworld): @@ -248,13 +299,11 @@ proc loadBots*( game.heroVms[slot] = HeroVm( runtime: initRuntime( program, - buildHeroHost(int32(100 + slot), chat), + buildHeroHost(int32(100 + slot)), limits ), ready: true, - prepareDecision: chat.decisionCallback(), ) - chat.bindRuntime(game.heroVms[slot].runtime) when defined(coworld): game.heroVms[slot].output = playerPrinter(int(slot)) @@ -271,10 +320,7 @@ proc runBotDecisions*(game: Game, slot: int32) {.measure.} = activeHeroSlot = slot let objective = game.objectiveTile(slot) try: - if game.heroVms[slot].prepareDecision != nil: - game.heroVms[slot].prepareDecision(game.world.tick) - else: - game.heroVms[slot].runtime.restart() + game.heroVms[slot].runtime.restart() game.heroVms[slot].runtime.setData(heroDataIds[DataSelfId], actor.id) game.heroVms[slot].runtime.setData( heroDataIds[DataSelfClass], diff --git a/examples/call_to_adventure/sim.nim b/examples/call_to_adventure/sim.nim index 88aa3e95..9e7b55c5 100644 --- a/examples/call_to_adventure/sim.nim +++ b/examples/call_to_adventure/sim.nim @@ -19,7 +19,6 @@ import type HeroVm* = ref object output*: PrintProc - prepareDecision*: proc(tick: int32) {.closure.} ## One compiled BASIC program for a party slot. Not simulation state. runtime*: Runtime ready*: bool @@ -42,7 +41,7 @@ type historyPlayback*: bool replayMode*: bool heroVms*: array[PartySize, HeroVm] - mailboxes*: Mailboxes + inboxes*: array[PartySize, Mailbox] const AggroTiles* = 9'i32 diff --git a/examples/gods_of_the_arena/bots.nim b/examples/gods_of_the_arena/bots.nim index 477f19a5..286851ba 100644 --- a/examples/gods_of_the_arena/bots.nim +++ b/examples/gods_of_the_arena/bots.nim @@ -3,7 +3,7 @@ import bassy, fixxy, - polyworld/[chats, mailboxes, metrics, bodies, cli, controllers, + polyworld/[mailboxes, metrics, bodies, cli, controllers, pathing, profiles, tapes], content, maps, @@ -261,11 +261,64 @@ proc abilityProc(heroId: int32, field: AbilityField): HostProc = of AbilityRestore: spec.restore of AbilityManaCost: spec.manaCost -proc initHeroHost(heroId: int32, chat: ChatHost = nil): Host = +proc sendChat*( + game: Game, sender, target: int, text: openArray[char] +): int32 = + ## Routes chat according to this game's player and team rules. + if sender notin 0 ..< game.inboxes.len or + target < -2 or target >= game.inboxes.len: + return 0 + let id = int32(if target < 0: target else: sender) + for recipient in 0 ..< game.inboxes.len: + case target + of -2: + discard + of -1: + if game.world.heroes[recipient].team != game.world.heroes[sender].team: + continue + else: + if recipient != target: + continue + if game.inboxes[recipient].push(id, text): + inc result + +proc initHeroHost(heroId: int32): Host = ## Builds the bounded world-query and action interface for one hero. result = initHost() - let services = if chat == nil: newChatHost(0) else: chat - services.addFunctions(result) + let sendChatProc: NumericHostProc = proc(args: openArray[Value]): Value = + ## Sends script text through the game's routing rules. + let player = activeGame.world.heroIndex(heroId) + activeGame.heroVms[player].runtime.withString(args[1], text): + result = activeGame.sendChat(player, int(args[0].asInt), text) + let pullMailboxProc: NumericHostProc = proc(args: openArray[Value]): Value = + ## Copies the oldest message into BASIC and consumes it on success. + let + player = activeGame.world.heroIndex(heroId) + inbox = activeGame.inboxes[player] + var runtime = activeGame.heroVms[player].runtime + if inbox.count == 0: + result = runtime.putString("") + else: + result = runtime.putString(inbox.messages[inbox.first]) + discard inbox.pop() + let mailboxIdProc: HostProc = proc(args: openArray[int32]): int32 = + ## Returns the channel or DM sender of the last pulled message. + activeGame.inboxes[activeGame.world.heroIndex(heroId)].lastId + let mailboxCountProc: HostProc = proc(args: openArray[int32]): int32 = + ## Counts this player's unread messages. + int32(activeGame.inboxes[activeGame.world.heroIndex(heroId)].count) + let mailboxSelfProc: HostProc = proc(args: openArray[int32]): int32 = + ## Returns this player's zero-based mailbox address. + int32(activeGame.world.heroIndex(heroId)) + let mailboxPlayersProc: HostProc = proc(args: openArray[int32]): int32 = + ## Returns the number of player mailboxes in this game. + int32(activeGame.inboxes.len) + discard result.addFunction("sendChat", 2, sendChatProc, 256) + discard result.addFunction("pullMailbox$", 0, pullMailboxProc, 256) + discard result.addFunction("mailboxId", 0, mailboxIdProc, 4) + discard result.addFunction("mailboxCount", 0, mailboxCountProc, 4) + discard result.addFunction("mailboxSelf", 0, mailboxSelfProc, 4) + discard result.addFunction("mailboxPlayers", 0, mailboxPlayersProc, 4) for error in ActionError: discard result.addData($error, error.ord.int32) for class in HeroClass: @@ -802,15 +855,13 @@ proc loadBots*( kinds = controllerKinds(game.world.heroes.len, playerSlot) sources = groups.expandBotSources(kinds) game.heroVms.setLen(game.world.heroes.len) - game.mailboxes.reset(game.world.heroes.len) - for i, hero in game.world.heroes: - game.mailboxes.teams[i] = int32(hero.team) + game.inboxes.setLen(game.world.heroes.len) + for inbox in game.inboxes.mitems: + inbox = newMailbox() var bound = false for i in 0 ..< game.world.heroes.len: if kinds[i] == PlayerController: continue - let chat = newChatHost(i) - chat.mailboxes = game.mailboxes let source = sources[i] let program = when defined(coworld): @@ -823,14 +874,12 @@ proc loadBots*( game.heroVms[i] = HeroVm( runtime: initRuntime( program, - initHeroHost(game.world.heroes[i].id, chat), + initHeroHost(game.world.heroes[i].id), limits ), limits: limits, - prepareDecision: chat.decisionCallback(), ready: true ) - chat.bindRuntime(game.heroVms[i].runtime) when defined(coworld): game.heroVms[i].output = playerPrinter(int(i)) @@ -844,10 +893,7 @@ proc runHeroScript(game: Game, index: int) = if vm == nil or vm.failed: return try: - if vm.prepareDecision != nil: - vm.prepareDecision(game.world.tick) - else: - vm.runtime.restart() + vm.runtime.restart() discard game.world.worldObjectCount(hero.id) vm.runtime.setData(heroDataIds[DataSelfId], hero.id) vm.runtime.setData(heroDataIds[DataSelfTeam], int32(hero.team.ord)) diff --git a/examples/gods_of_the_arena/sim.nim b/examples/gods_of_the_arena/sim.nim index 77a9cbcf..0e23a3ad 100644 --- a/examples/gods_of_the_arena/sim.nim +++ b/examples/gods_of_the_arena/sim.nim @@ -67,7 +67,6 @@ type HeroVm* = ref object output*: PrintProc - prepareDecision*: proc(tick: int32) {.closure.} runtime*: Runtime limits*: Limits ready*: bool @@ -321,7 +320,7 @@ type replayMode*: bool recordingError*: string heroVms*: seq[HeroVm] - mailboxes*: Mailboxes + inboxes*: seq[Mailbox] nextFootmen: seq[Footman] nextHeroes: seq[Hero] collisionUnits: seq[CollisionUnit] diff --git a/examples/light_vs_dark/bots.nim b/examples/light_vs_dark/bots.nim index 1829060e..d80e7dea 100644 --- a/examples/light_vs_dark/bots.nim +++ b/examples/light_vs_dark/bots.nim @@ -12,7 +12,7 @@ import bassy, - polyworld/[chats, mailboxes, bodies, metrics, profiles], + polyworld/[mailboxes, bodies, metrics, profiles], content, sim @@ -268,7 +268,28 @@ proc observedAt(index: int32): Observed = return Observed(owner: -1) snapshot[index] -proc buildOverlordHost*(playerId: int32, chat: ChatHost = nil): Host = +proc sendChat*( + game: Game, sender, target: int, text: openArray[char] +): int32 = + ## Routes chat according to this game's player and team rules. + if sender notin 0 ..< game.inboxes.len or + target < -2 or target >= game.inboxes.len: + return 0 + let id = int32(if target < 0: target else: sender) + for recipient in 0 ..< game.inboxes.len: + case target + of -2: + discard + of -1: + if recipient != sender: + continue + else: + if recipient != target: + continue + if game.inboxes[recipient].push(id, text): + inc result + +proc buildOverlordHost*(playerId: int32): Host = ## Builds the complete world-query and command interface for one player. ## ## The same builder makes both the compile-time schema and each player's @@ -281,8 +302,40 @@ proc buildOverlordHost*(playerId: int32, chat: ChatHost = nil): Host = ## cost far more than their own cycles, so a script's budget prices its ## demand on the simulation rather than only its own arithmetic. result = initHost() - let services = if chat == nil: newChatHost(0) else: chat - services.addFunctions(result) + let sendChatProc: NumericHostProc = proc(args: openArray[Value]): Value = + ## Sends script text through the game's routing rules. + let player = int(playerId) + activeGame.brains[player].runtime.withString(args[1], text): + result = activeGame.sendChat(player, int(args[0].asInt), text) + let pullMailboxProc: NumericHostProc = proc(args: openArray[Value]): Value = + ## Copies the oldest message into BASIC and consumes it on success. + let + player = int(playerId) + inbox = activeGame.inboxes[player] + var runtime = activeGame.brains[player].runtime + if inbox.count == 0: + result = runtime.putString("") + else: + result = runtime.putString(inbox.messages[inbox.first]) + discard inbox.pop() + let mailboxIdProc: HostProc = proc(args: openArray[int32]): int32 = + ## Returns the channel or DM sender of the last pulled message. + activeGame.inboxes[int(playerId)].lastId + let mailboxCountProc: HostProc = proc(args: openArray[int32]): int32 = + ## Counts this player's unread messages. + int32(activeGame.inboxes[int(playerId)].count) + let mailboxSelfProc: HostProc = proc(args: openArray[int32]): int32 = + ## Returns this player's zero-based mailbox address. + int32(int(playerId)) + let mailboxPlayersProc: HostProc = proc(args: openArray[int32]): int32 = + ## Returns the number of player mailboxes in this game. + int32(activeGame.inboxes.len) + discard result.addFunction("sendChat", 2, sendChatProc, 256) + discard result.addFunction("pullMailbox$", 0, pullMailboxProc, 256) + discard result.addFunction("mailboxId", 0, mailboxIdProc, 4) + discard result.addFunction("mailboxCount", 0, mailboxCountProc, 4) + discard result.addFunction("mailboxSelf", 0, mailboxSelfProc, 4) + discard result.addFunction("mailboxPlayers", 0, mailboxPlayersProc, 4) for name in OverlordDataNames: discard result.addData(name) @@ -550,16 +603,13 @@ proc loadBots*( ## Compiles one script per player and gives each its own runtime. let limits = overlordLimits() let schema = buildOverlordHost(0) - game.mailboxes.reset(PlayerCount) - for player in 0 ..< PlayerCount: - game.mailboxes.teams[player] = int32(player) + for inbox in game.inboxes.mitems: + inbox = newMailbox() var bound = false for player in 0'i32 ..< PlayerCount: when not defined(coworld): if sources[player].len == 0: continue - let chat = newChatHost(int(player)) - chat.mailboxes = game.mailboxes let source = sources[player] let program = when defined(coworld): @@ -567,11 +617,9 @@ proc loadBots*( else: compile(source, schema, limits) game.brains[player] = OverlordVm( - runtime: initRuntime(program, buildOverlordHost(player, chat), limits), + runtime: initRuntime(program, buildOverlordHost(player), limits), ready: true, - prepareDecision: chat.decisionCallback(), ) - chat.bindRuntime(game.brains[player].runtime) if not bound: bindOverlordData(program) bound = true @@ -596,10 +644,7 @@ proc runDecision(game: Game, player: int32) = break try: - if game.brains[player].prepareDecision != nil: - game.brains[player].prepareDecision(game.world.tick) - else: - game.brains[player].runtime.restart() + game.brains[player].runtime.restart() let economy = addr game.world.players[player] ids = overlordDataIds diff --git a/examples/light_vs_dark/sim.nim b/examples/light_vs_dark/sim.nim index 3ebdb07d..cfb3f898 100644 --- a/examples/light_vs_dark/sim.nim +++ b/examples/light_vs_dark/sim.nim @@ -133,7 +133,6 @@ type explored*: array[PlayerCount, seq[uint8]] # HASH: derived OverlordVm* = ref object output*: PrintProc - prepareDecision*: proc(tick: int32) {.closure.} ## One compiled BASIC program for a player. Not simulation state. runtime*: Runtime ready*: bool @@ -154,7 +153,7 @@ type historyPlayback*: bool replayMode*: bool brains*: array[PlayerCount, OverlordVm] - mailboxes*: Mailboxes + inboxes*: array[PlayerCount, Mailbox] mapSeed*: int32 maximumTicks*: int32 diff --git a/examples/mailboxes/chat.bas b/examples/mailboxes/chat.bas index bee2f82b..6c6ef898 100644 --- a/examples/mailboxes/chat.bas +++ b/examples/mailboxes/chat.bas @@ -1,7 +1,7 @@ ' Drain all unread messages. Empty text means the queue is empty. message$ = pullMailbox$() while message$ <> "" - print mailboxId(), mailboxSender(), mailboxTick(), message$ + print mailboxId(), message$ message$ = pullMailbox$() wend if announced = 0 then diff --git a/nimby.lock b/nimby.lock index 35a5d928..50ca4a84 100644 --- a/nimby.lock +++ b/nimby.lock @@ -1,4 +1,4 @@ -bassy 0.1.0 https://github.com/treeform/bassy b25e0efef3fec0bd86ed3154659c0762a7158bd3 +bassy 0.1.0 https://github.com/treeform/bassy 669a7c4b94e3d5b0a38dc9557608a7e58c2a764d fixxy 0.1.0 https://github.com/treeform/fixxy 05e5446dffb70093056cebb0c57721a60deaf52a silky 0.2.0 https://github.com/treeform/silky fb9b13910edd66cf1751056784c2f7d2932a59fc pixie 6.1.0 https://github.com/treeform/pixie 87cecced5c4c6f311c658a5f3ca0c9b43edb6aa7 diff --git a/src/polyworld/chats.nim b/src/polyworld/chats.nim deleted file mode 100644 index 9b5dd3dc..00000000 --- a/src/polyworld/chats.nim +++ /dev/null @@ -1,84 +0,0 @@ -import - bassy, - mailboxes, scripts - -type - ChatHost* = ref object - runtime {.cursor.}: Runtime - scratch: ScriptScratch - slot: int - mailboxes*: Mailboxes - ChatFunction = enum - SendChat, PullMailbox, MailboxSender, MailboxTick, - MailboxCount, MailboxSelf, MailboxPlayers, MailboxId - -const - FunctionNames: array[ChatFunction, string] = [ - "sendChat", "pullMailbox$", "mailboxSender", - "mailboxTick", "mailboxCount", "mailboxSelf", "mailboxPlayers", "mailboxId" - ] - FunctionParameters: array[ChatFunction, int] = [2, 0, 0, 0, 0, 0, 0, 0] - -proc newChatHost*(slot: int, mailboxes: Mailboxes = nil): ChatHost = - ## Binds a player's BASIC host to its own mailbox address. - ChatHost(slot: slot, mailboxes: mailboxes) - -proc bindRuntime*(host: ChatHost, runtime: Runtime) = - ## Borrows the runtime owning these callbacks without a reference cycle. - host.runtime = runtime - host.scratch = newScriptScratch(runtime) - -proc beginTick*(host: ChatHost, tick: int32) = - ## Restarts BASIC using reserved scratch and advances message timestamps. - if host.runtime != nil: - host.runtime.restartScript(host.scratch) - host.mailboxes.beginTick(tick) - -proc decisionCallback*(host: ChatHost): proc(tick: int32) = - ## Binds mailbox preparation to the game's decision boundary. - result = proc(tick: int32) = - ## Advances the shared router for this decision. - host.beginTick(tick) - -proc callback(host: ChatHost, kind: ChatFunction): NumericHostProc = - ## Exposes only this player's queue to the BASIC runtime. - result = proc(arguments: openArray[Value]): Value = - ## Converts bounded BASIC strings and mailbox addresses. - template output(value: string): Value = - ## Stores returned message text in BASIC's bounded string pool. - host.runtime.putScriptText(value) - case kind - of SendChat: - host.runtime.withScriptText(arguments[1], text): - result = host.mailboxes.send(host.slot, int(arguments[0].asInt()), text) - of PullMailbox: - let message = host.mailboxes.pull(host.slot) - if message == nil: - result = output("") - else: - message.withText(text): - result = host.runtime.putScriptText(text) - of MailboxSender: - let last = host.mailboxes.last(host.slot) - result = int32(if last.len == 0: -1 else: last.sender) - of MailboxTick: - let last = host.mailboxes.last(host.slot) - result = if last.len == 0: 0'i32 else: last.tick - of MailboxCount: - result = host.mailboxes.count(host.slot) - of MailboxSelf: - result = int32(host.slot) - of MailboxPlayers: - result = int32(host.mailboxes.players()) - of MailboxId: - result = int32(host.mailboxes.last(host.slot).id()) - -proc addFunctions*(host: ChatHost, basic: var Host) = - ## Registers player-to-player communication without external services. - for kind in ChatFunction: - discard basic.addFunction( - FunctionNames[kind], - FunctionParameters[kind], - host.callback(kind), - 256 - ) diff --git a/src/polyworld/mailboxes.nim b/src/polyworld/mailboxes.nim index 1c26171e..5cd04771 100644 --- a/src/polyworld/mailboxes.nim +++ b/src/polyworld/mailboxes.nim @@ -1,213 +1,38 @@ const - MaxMailboxPlayers* = 64 - MaxMailboxMessages* = 128 + MaxMailboxMessages* = 100 MaxChatBytes* = 1024 - GlobalMailboxId* = -2 - TeamMailboxId* = -1 NoMailboxId* = -3 -type - MailboxError* = object of CatchableError - MailChannel* = enum - DirectMailbox, TeamMailbox, GlobalMailbox - MailMessage* = ref object - sender*, target*: int - tick*: int32 - channel*: MailChannel - length: int - bytes: array[MaxChatBytes, char] - MailRule* = proc(message: MailMessage, recipient: int): bool {.closure.} - Mailbox = ref object - messages: array[MaxMailboxMessages, MailMessage] - first, count: int - last: MailMessage - Mailboxes* = ref object - teams*: array[MaxMailboxPlayers, int32] - rule*: MailRule - boxes: array[MaxMailboxPlayers, Mailbox] - draft: MailMessage - playerCount: int - tick: int32 - -proc newMailboxes*(players: int): Mailboxes = - ## Creates private queues with an unrestricted common team by default. - if players < 1 or players > MaxMailboxPlayers: - raise newException(MailboxError, "Mailbox player count must be 1 .. 64") - result = Mailboxes(playerCount: players, draft: MailMessage(), tick: -1) - for i in 0 ..< players: - result.boxes[i] = Mailbox(last: MailMessage()) - for slot in 0 ..< MaxMailboxMessages: - result.boxes[i].messages[slot] = MailMessage() - -proc players*(mailboxes: Mailboxes): int = - ## Reports the number of zero-based player addresses. - if mailboxes == nil: 0 else: mailboxes.playerCount - -proc clear*(mailboxes: Mailboxes) = - ## Empties queues without allocating or releasing their backing storage. - if mailboxes == nil: - return - for i in 0 ..< mailboxes.players: - let box = mailboxes.boxes[i] - box.first = 0 - box.count = 0 - box.last.length = 0 - box.last.tick = 0 - mailboxes.draft.length = 0 - -proc reset*(mailboxes: var Mailboxes, players: int) = - ## Starts a new roster while retaining rules for an unchanged game size. - if mailboxes == nil or mailboxes.players != players: - mailboxes = newMailboxes(players) - else: - mailboxes.clear() - mailboxes.tick = -1 - -proc beginTick*(mailboxes: Mailboxes, tick: int32) = - ## Preserves unread mail across ticks and clears it on a match reset. - if mailboxes == nil: - return - if tick < mailboxes.tick: - mailboxes.clear() - mailboxes.tick = tick - -proc len*(message: MailMessage): int {.raises: [].} = - ## Returns the occupied byte count, including zero for an empty pull. - if message == nil: 0 else: message.length - -template withText*(message: MailMessage, text, body: untyped) = - ## Borrows bytes until the next pull, clear, or roster reset. - block: - let envelope = message - assert envelope != nil - template text: untyped = - envelope.bytes.toOpenArray(0, envelope.length - 1) - body - -proc matches*(message: MailMessage, text: openArray[char]): bool = - ## Compares a message without constructing a temporary string. - if message.len != text.len: - return false - for i in 0 ..< text.len: - if message.bytes[i] != text[i]: - return false - true - -proc id*(message: MailMessage): int = - ## Returns a broadcast address or the sender of a direct message. - if message.len == 0: - return NoMailboxId - case message.channel - of DirectMailbox: message.sender - of TeamMailbox: TeamMailboxId - of GlobalMailbox: GlobalMailboxId - -proc validText(text: openArray[char]): bool = - ## Rejects malformed, overlong, surrogate, and out-of-range UTF-8 sequences. - var i = 0 - while i < text.len: - let first = ord(text[i]) - var - extra = 0 - low = 0x80 - high = 0xbf - case first - of 0 .. 0x7f: - inc i - continue - of 0xc2 .. 0xdf: - extra = 1 - of 0xe0 .. 0xef: - extra = 2 - if first == 0xe0: - low = 0xa0 - elif first == 0xed: - high = 0x9f - of 0xf0 .. 0xf4: - extra = 3 - if first == 0xf0: - low = 0x90 - elif first == 0xf4: - high = 0x8f - else: +type Mailbox* = ref object + ids*: array[MaxMailboxMessages, int32] + messages*: array[MaxMailboxMessages, string] + first*, count*: int + lastId*: int32 + +proc newMailbox*(): Mailbox = + ## Reserves one player's bounded inbox before the game starts. + result = Mailbox(lastId: NoMailboxId) + for message in result.messages.mitems: + message = newStringOfCap(MaxChatBytes) + +proc push*(mailbox: Mailbox, id: int32, text: openArray[char]): bool = + ## Appends an ID and text, ignoring empty, oversized, or excess messages. + if mailbox.count == MaxMailboxMessages or + text.len == 0 or text.len > MaxChatBytes: return false - if extra >= text.len - i or ord(text[i + 1]) notin low .. high: - return false - for j in 2 .. extra: - if ord(text[i + j]) notin 0x80 .. 0xbf: - return false - i += extra + 1 - true - -proc write( - message: MailMessage, sender, target: int, tick: int32, - channel: MailChannel, text: openArray[char] -) = - ## Copies payload bytes into an existing envelope without replacing it. - message.sender = sender - message.target = target - message.tick = tick - message.channel = channel - message.length = text.len + let index = (mailbox.first + mailbox.count) mod MaxMailboxMessages + mailbox.ids[index] = id + mailbox.messages[index].setLen(text.len) for i in 0 ..< text.len: - message.bytes[i] = text[i] - -proc send*( - mailboxes: Mailboxes, sender, target: int, text: openArray[char] -): int32 = - ## Fans out a message through the game's audience and delivery rules. - if mailboxes == nil or sender < 0 or sender >= mailboxes.players or - text.len == 0 or text.len > MaxChatBytes or not validText(text): - return 0 - if target < GlobalMailboxId or target >= mailboxes.players: - return 0 - let channel = - case target - of GlobalMailboxId: GlobalMailbox - of TeamMailboxId: TeamMailbox - else: DirectMailbox - mailboxes.draft.write(sender, target, mailboxes.tick, channel, text) - for recipient in 0 ..< mailboxes.players: - let box = mailboxes.boxes[recipient] - case channel - of DirectMailbox: - if recipient != target: - continue - of TeamMailbox: - if mailboxes.teams[recipient] != mailboxes.teams[sender]: - continue - of GlobalMailbox: - discard - if box.count == MaxMailboxMessages: - continue - if mailboxes.rule != nil and not mailboxes.rule(mailboxes.draft, recipient): - continue - box.messages[(box.first + box.count) mod MaxMailboxMessages].write( - sender, target, mailboxes.tick, channel, text - ) - inc box.count - inc result - -proc count*(mailboxes: Mailboxes, recipient: int): int32 = - ## Counts this recipient's unread messages without consuming them. - if mailboxes != nil and recipient in 0 ..< mailboxes.players: - result = int32(mailboxes.boxes[recipient].count) - -proc pull*(mailboxes: Mailboxes, recipient: int): MailMessage = - ## Borrows the popped envelope until this player's next pull or reset. - if mailboxes == nil or recipient notin 0 ..< mailboxes.players: - return - let box = mailboxes.boxes[recipient] - box.last.length = 0 - box.last.tick = 0 - if box.count == 0: - return box.last - swap(box.last, box.messages[box.first]) - result = box.last - box.first = (box.first + 1) mod MaxMailboxMessages - dec box.count + mailbox.messages[index][i] = text[i] + inc mailbox.count + true -proc last*(mailboxes: Mailboxes, recipient: int): MailMessage = - ## Reads metadata for the recipient's most recent pull operation. - if mailboxes != nil and recipient in 0 ..< mailboxes.players: - result = mailboxes.boxes[recipient].last +proc pop*(mailbox: Mailbox): int32 = + ## Consumes the first message after the game has read its text. + mailbox.lastId = NoMailboxId + if mailbox.count > 0: + mailbox.lastId = mailbox.ids[mailbox.first] + mailbox.first = (mailbox.first + 1) mod MaxMailboxMessages + dec mailbox.count + mailbox.lastId diff --git a/src/polyworld/scripts.nim b/src/polyworld/scripts.nim deleted file mode 100644 index c0a23b27..00000000 --- a/src/polyworld/scripts.nim +++ /dev/null @@ -1,84 +0,0 @@ -import - std/importutils, - bassy, bassy/texts - -type ScriptScratch* = ref object - roots: seq[Value] - -proc newScriptScratch*(runtime: Runtime): ScriptScratch = - ## Allocates string-compaction roots once when binding a runtime. - privateAccess(Runtime) - privateAccess(Program) - result = ScriptScratch() - if runtime.program.usesStrings: - result.roots.setLen( - runtime.globals.len + runtime.memory.len + runtime.hostData.len - ) - -proc restartScript*(runtime: var Runtime, scratch: ScriptScratch) = - ## Reclaims temporary strings while preserving persistent script state. - privateAccess(Runtime) - privateAccess(Program) - if runtime.program.usesStrings: - # The pinned Bassy version only compacts strings on a full reset. - # Preserve all live roots with that compactor at decision boundaries. - let - globals = runtime.globals.len - cells = runtime.memory.len - assert scratch.roots.len == globals + cells + runtime.hostData.len - for i, value in runtime.globals: - scratch.roots[i] = value - for i, value in runtime.memory: - scratch.roots[globals + i] = value - for i, value in runtime.hostData: - scratch.roots[globals + cells + i] = value - runtime.strings.reset(scratch.roots) - for i in 0 ..< globals: - runtime.globals[i] = scratch.roots[i] - for i in 0 ..< cells: - runtime.memory[i] = scratch.roots[globals + i] - for i in 0 ..< runtime.hostData.len: - runtime.hostData[i] = scratch.roots[globals + cells + i] - for handle in runtime.stringLiterals.mitems: - handle = -1 - runtime.restart() - -template withScriptText*( - runtime: Runtime, value: Value, text, body: untyped -) = - ## Borrows validated BASIC bytes for the duration of a host callback. - block: - privateAccess(Runtime) - privateAccess(TextStorage) - let - owner = runtime - input = value - length = owner.strings.length(input) - privateAccess(typeof(owner.strings.spans[0])) - let start = int(owner.strings.spans[int(input.stringHandle)].start) - template text: untyped = - owner.strings.arena.toOpenArray(start, start + length - 1) - body - -proc putScriptText*(runtime: var Runtime, text: openArray[char]): Value = - ## Copies bytes directly into BASIC's preallocated string arena. - privateAccess(Runtime) - privateAccess(TextStorage) - privateAccess(typeof(runtime.strings.spans[0])) - if text.len == 0: - return runtime.strings.empty - if runtime.strings.owner == 0: - raise newException(BasicError, "BASIC program has no string storage") - if text.len > runtime.strings.maxLength: - raise newException(BasicError, "BASIC string length limit exceeded") - if runtime.strings.spans.len >= runtime.strings.maxCount: - raise newException(BasicError, "BASIC string count limit exceeded") - if text.len > runtime.strings.maxBytes - runtime.strings.arena.len: - raise newException(BasicError, "BASIC string byte limit exceeded") - let handle = runtime.strings.spans.len - runtime.strings.spans.setLen(handle + 1) - runtime.strings.spans[handle].start = int32(runtime.strings.arena.len) - runtime.strings.spans[handle].length = int32(text.len) - for character in text: - runtime.strings.arena.add character - stringValue(runtime.strings.owner, int32(handle)) diff --git a/tests/test_chats.nim b/tests/test_chats.nim index b99e1f90..ff509b04 100644 --- a/tests/test_chats.nim +++ b/tests/test_chats.nim @@ -22,12 +22,43 @@ from = mailboxId() proc checkMailboxes[T](game: T, teamCount: int) = ## Checks default chat routing and repeated reads through a game's hosts. - doAssert game.mailboxes != nil - doAssert game.mailboxes.send(0, TeamMailboxId, "team") == teamCount - for slot in 0 ..< game.mailboxes.players: - let message = game.mailboxes.pull(slot) - doAssert (message.matches("team")) == - (game.mailboxes.teams[slot] == game.mailboxes.teams[0]) + template send(sender, target, text: untyped): untyped = + ## Uses the game's own routing implementation. + when T is ctaSim.Game: + ctaBots.sendChat(game, sender, target, text) + elif T is gotaSim.Game: + gotaBots.sendChat(game, sender, target, text) + else: + lvdBots.sendChat(game, sender, target, text) + doAssert send(0, -1, "team") == teamCount + var received = 0 + for slot, inbox in game.inboxes: + when T is ctaSim.Game: + let teammate = true + elif T is gotaSim.Game: + let teammate = game.world.heroes[slot].team == game.world.heroes[0].team + else: + let teammate = slot == 0 + doAssert inbox.count == int(teammate) + if inbox.count > 0: + inc received + doAssert inbox.messages[inbox.first] == "team" + doAssert inbox.pop() == -1 + doAssert received == teamCount + doAssert send(0, -2, "global") == game.inboxes.len + for inbox in game.inboxes: + doAssert inbox.pop() == -2 + doAssert send(0, 1, "direct") == 1 + doAssert game.inboxes[1].messages[game.inboxes[1].first] == "direct" + doAssert game.inboxes[1].pop() == 0 + doAssert send(0, game.inboxes.len, "invalid") == 0 + for i in 0 ..< MaxMailboxMessages: + doAssert send(0, 0, "full") == 1 + doAssert send(0, -2, "partial") == game.inboxes.len - 1 + doAssert game.inboxes[0].count == MaxMailboxMessages + for inbox in game.inboxes: + while inbox.count > 0: + discard inbox.pop() for tick in 1 .. 300: game.world.tick = int32(tick) when T is ctaSim.Game: @@ -49,6 +80,16 @@ proc checkMailboxes[T](game: T, teamCount: int) = "private hello" let large = vm.runtime.putString(repeat('x', 1024)) doAssert vm.runtime.getString(large).len == 1024 + when defined(nimAllocStats) and T is gotaSim.Game: + # The GotA decision runner leaves its active game bound for host calls. + # Isolate mailbox callbacks and restart from unrelated world preparation. + let before = getAllocStats() + for decision in 0 ..< 1000: + for vm in game.heroVms: + vm.runtime.restart() + discard vm.runtime.run() + let after = getAllocStats() + doAssert after == before, $(after - before) echo "Testing default mailboxes through all three games' BASIC hosts" block: @@ -68,12 +109,18 @@ block: drafting = false ) gotaBots.loadBots(gota, [BotGroup(path: path, count: 10)]) + echo "Checking GotA" gota.checkMailboxes(5) + echo "GotA passed" let cta = ctaSim.newGame(2026) ctaBots.loadBots(cta, [BotGroup(path: path, count: ctaContent.PartySize)]) + echo "Checking CTA" cta.checkMailboxes(ctaContent.PartySize) + echo "CTA passed" let lvd = lvdSim.newGame(lvdMaps.generateMap(lvdContent.DefaultSeed), 240) lvdBots.loadBots(lvd, [Program, Program]) + echo "Checking LVD" lvd.checkMailboxes(1) + echo "LVD passed" diff --git a/tests/test_gota_base.nim b/tests/test_gota_base.nim index 7cafead4..47eb6691 100644 --- a/tests/test_gota_base.nim +++ b/tests/test_gota_base.nim @@ -79,6 +79,10 @@ block: names.incl(name & "At") doAssert names.len >= 68 for name in names: + # Chat is exercised by the mailbox example instead of the combat policy. + if name in ["sendChat", "pullMailbox$", "mailboxId", "mailboxCount", + "mailboxSelf", "mailboxPlayers"]: + continue doAssert name & "(" in source, "Base policy omits host call " & name echo "Testing base spends ability points in R, W, E, Q order at legal levels" diff --git a/tests/test_mailbox_allocations.nim b/tests/test_mailbox_allocations.nim index 8280b282..a3a5b748 100644 --- a/tests/test_mailbox_allocations.nim +++ b/tests/test_mailbox_allocations.nim @@ -1,74 +1,24 @@ import std/strutils, - bassy, - polyworld/[chats, mailboxes] + polyworld/mailboxes, + test_chats when not defined(nimAllocStats): {.error: "Run this test with -d:nimAllocStats to measure allocations.".} -echo "Testing mailbox rings allocate only during initialization" -block: - var mail = newMailboxes(4) - let payload = repeat('x', MaxChatBytes) - mail.rule = proc(message: MailMessage, recipient: int): bool = - ## Applies a deterministic routing rule without allocating. - message.sender >= 0 and recipient >= 0 - let before = getAllocStats() - for round in 0 ..< 200: - mail.beginTick(int32(round)) - for slot in 0 ..< MaxMailboxMessages: - doAssert mail.send(0, GlobalMailboxId, payload) == 4 - doAssert mail.send(0, TeamMailboxId, "overflow") == 0 - doAssert mail.pull(0).matches(payload) - doAssert mail.send(1, 0, payload) == 1 - for player in 0 ..< 4: - while mail.count(player) > 0: - doAssert mail.pull(player).matches(payload) - doAssert mail.pull(player).len == 0 - doAssert mail.send(0, TeamMailboxId, "team") == 4 - mail.clear() - doAssert mail.send(0, 1, "reset") == 1 - mail.beginTick(-1) - doAssert mail.count(1) == 0 - mail.reset(4) - let after = getAllocStats() - doAssert after == before, $(after - before) - -echo "Testing BASIC send, pull, and decision restart allocate no heap memory" +echo "Testing inbox storage is reused without heap allocations" block: let - mail = newMailboxes(1) - chat = newChatHost(0, mail) + inbox = newMailbox() payload = repeat('x', MaxChatBytes) - var - host = initHost() - limits = defaultLimits() - chat.addFunctions(host) - limits.maxStringBytes = 256 * 1024 - limits.maxWorkUnits = 300_000 - let program = compile(""" -sent = 0 -for i = 1 to 128 - sent = sent + sendChat(mailboxSelf(), payload$) -next i -overflow = sendChat(-2, payload$) -received = 0 -message$ = pullMailbox$() -while message$ <> "" - received = received + 1 - sender = mailboxId() - message$ = pullMailbox$() -wend -""", host, limits) - var runtime = initRuntime(program, host, limits) - chat.bindRuntime(runtime) - runtime.setGlobal("payload$", payload) - let before = getAllocStats() - for tick in 0 ..< 1000: - chat.beginTick(int32(tick)) - discard runtime.run() + before = getAllocStats() + for round in 0 ..< 1000: + for i in 0 ..< MaxMailboxMessages: + doAssert inbox.push(int32(i), payload) + doAssert not inbox.push(0, "overflow") + for i in 0 ..< MaxMailboxMessages: + doAssert inbox.messages[inbox.first] == payload + doAssert inbox.pop() == int32(i) + doAssert inbox.pop() == NoMailboxId let after = getAllocStats() doAssert after == before, $(after - before) - doAssert runtime.getGlobal("sent") == 128 - doAssert runtime.getGlobal("overflow") == 0 - doAssert runtime.getGlobal("received") == 128 diff --git a/tests/test_mailboxes.nim b/tests/test_mailboxes.nim index f19e7efa..7a5d81d1 100644 --- a/tests/test_mailboxes.nim +++ b/tests/test_mailboxes.nim @@ -1,134 +1,26 @@ import std/strutils, - bassy, - polyworld/[chats, mailboxes] + polyworld/mailboxes -echo "Testing private queues, broadcasts, DMs, and received message IDs" +echo "Testing bounded inbox IDs, strings, overflow, and wraparound" block: - let mail = newMailboxes(3) - mail.beginTick(12) - doAssert mail.pull(0).matches("") - doAssert mail.last(0).id == NoMailboxId - doAssert mail.send(0, -2, "hello") == 3 - doAssert mail.send(1, 0, "private") == 1 - doAssert mail.pull(0).matches("hello") - doAssert mail.last(0).id == -2 - doAssert mail.last(0).sender == 0 - doAssert mail.pull(0).matches("private") - doAssert mail.last(0).id == 1 - doAssert mail.last(0).sender == 1 - doAssert mail.last(0).target == 0 - doAssert mail.last(0).tick == 12 - doAssert mail.pull(0).matches("") - doAssert mail.last(0).id == NoMailboxId - doAssert mail.pull(1).matches("hello") - doAssert mail.pull(2).matches("hello") - doAssert mail.count(1) == 0 and mail.count(2) == 0 - -echo "Testing team membership and per-recipient game rules" -block: - let mail = newMailboxes(3) - doAssert mail.send(0, -1, "default team") == 3 - doAssert mail.pull(2).id == -1 - mail.clear() - mail.teams[2] = 1 - doAssert mail.send(0, -1, "allies") == 2 - doAssert mail.count(2) == 0 - mail.clear() - var positions = @[0, 1, 100] - mail.rule = proc(message: MailMessage, recipient: int): bool = - ## Models a game restricting every channel to local hearing range. - abs(positions[message.sender] - positions[recipient]) <= 5 - doAssert mail.send(0, -2, "nearby") == 2 - doAssert mail.send(0, 2, "too far") == 0 - positions[2] = 3 - doAssert mail.send(0, 2, "now near") == 1 - doAssert mail.pull(2).matches("now near") - -echo "Testing mailbox memory bounds, wraparound, and reset" -block: - let mail = newMailboxes(2) - mail.beginTick(10) - doAssert mail.send(0, 1, "") == 0 - doAssert mail.send(0, 2, "invalid target") == 0 - doAssert mail.send(0, -3, "invalid channel") == 0 - doAssert mail.send(0, 1, repeat('x', MaxChatBytes + 1)) == 0 - doAssert mail.send(0, 1, "\xff") == 0 - for invalid in ["\x80", "\xc0\x80", "\xc2", "\xe0\x80\x80", - "\xed\xa0\x80", "\xf0\x80\x80\x80", "\xf4\x90\x80\x80"]: - doAssert mail.send(0, 1, invalid) == 0 - let unicode = repeat('x', MaxChatBytes - 4) & "\xf0\x9f\x92\xa1" - doAssert mail.send(0, 1, unicode) == 1 - doAssert mail.pull(1).matches(unicode) + let inbox = newMailbox() + doAssert inbox.pop() == NoMailboxId + doAssert not inbox.push(1, "") + doAssert not inbox.push(1, repeat('x', MaxChatBytes + 1)) for i in 0 ..< MaxMailboxMessages: - doAssert mail.send(0, 1, $i) == 1 - doAssert mail.send(0, 1, "overflow") == 0 + doAssert inbox.push(int32(i), $i) + doAssert not inbox.push(999, "overflow") for i in 0 ..< 10: - doAssert mail.pull(1).matches($i) - doAssert mail.send(0, 1, $(i + MaxMailboxMessages)) == 1 + doAssert inbox.messages[inbox.first] == $i + doAssert inbox.pop() == int32(i) + doAssert inbox.push(int32(i + MaxMailboxMessages), $(i + MaxMailboxMessages)) for i in 10 ..< MaxMailboxMessages + 10: - doAssert mail.pull(1).matches($i) - doAssert mail.pull(1).matches("") - discard mail.send(0, -2, "old match") - mail.beginTick(11) - doAssert mail.count(1) == 1 - mail.beginTick(0) - doAssert mail.count(1) == 0 - -echo "Testing borrowed envelopes survive sends and full-queue broadcasts" -block: - let mail = newMailboxes(2) - for i in 0 ..< MaxMailboxMessages: - doAssert mail.send(0, 0, "queued") == 1 - doAssert mail.send(0, GlobalMailboxId, "partial") == 1 - let message = mail.pull(0) - doAssert mail.send(1, 0, "replacement") == 1 - doAssert message.matches("queued") - doAssert mail.last(0) == message - doAssert mail.pull(1).matches("partial") - for i in 1 ..< MaxMailboxMessages: - doAssert mail.pull(0).matches("queued") - doAssert mail.pull(0).matches("replacement") - mail.clear() - doAssert mail.last(0).id == NoMailboxId - -echo "Testing BASIC mailbox strings, sender IDs, and seat isolation" -block: - let - mail = newMailboxes(2) - sender = newChatHost(0) - receiver = newChatHost(1) - sender.mailboxes = mail - receiver.mailboxes = mail - var - firstHost = initHost() - secondHost = initHost() - sender.addFunctions(firstHost) - receiver.addFunctions(secondHost) - var first = initRuntime(compile(""" -sent = sendChat(1, "Hello from BASIC.") -own$ = pullMailbox$() -""", firstHost), firstHost) - var second = initRuntime(compile(""" -message$ = pullMailbox$() -from = mailboxId() -sentAt = mailboxTick() -left = mailboxCount() -empty$ = pullMailbox$() -missing = mailboxId() -""", secondHost), secondHost) - sender.bindRuntime(first) - receiver.bindRuntime(second) - sender.beginTick(9) - receiver.beginTick(9) - discard first.run() - discard second.run() - doAssert first.getGlobal("sent") == 1 - doAssert first.getString(first.getGlobalValue("own$")) == "" - doAssert second.getString(second.getGlobalValue("message$")) == - "Hello from BASIC." - doAssert second.getGlobal("from") == 0 - doAssert second.getGlobal("sentAt") == 9 - doAssert second.getGlobal("left") == 0 - doAssert second.getGlobal("missing") == NoMailboxId - doAssert second.getString(second.getGlobalValue("empty$")) == "" + doAssert inbox.messages[inbox.first] == $i + doAssert inbox.pop() == int32(i) + doAssert inbox.pop() == NoMailboxId + doAssert inbox.count == 0 + for id in [-2'i32, -1, 0, 9]: + doAssert inbox.push(id, "Hello 🌍") + doAssert inbox.messages[inbox.first] == "Hello 🌍" + doAssert inbox.pop() == id diff --git a/tests/test_scripts.nim b/tests/test_scripts.nim deleted file mode 100644 index 95a0ff54..00000000 --- a/tests/test_scripts.nim +++ /dev/null @@ -1,31 +0,0 @@ -import - bassy, - polyworld/scripts - -echo "Testing string reclamation preserves globals, arrays, and host data" -block: - var host = initHost() - discard host.addData("incoming$", "host value") - let program = compile(""" -dim saved$(2) -if turns = 0 then - saved$(0) = "persistent text" - saved$(1) = mid$(saved$(0), 2, 4) -end if -turns = turns + 1 -message$ = "turn " + str$(turns) + incoming$ -saved$(2) = message$ -""", host) - var runtime = initRuntime(program, host) - let scratch = newScriptScratch(runtime) - for i in 1 .. 1000: - runtime.restartScript(scratch) - discard runtime.run() - doAssert runtime.getGlobal("turns") == i - doAssert runtime.getStringArray("saved$", 0) == "persistent text" - doAssert runtime.getStringArray("saved$", 1) == "ersi" - doAssert runtime.getStringArray("saved$", 2) == - "turn " & $i & "host value" - doAssert runtime.getStringData("incoming$") == "host value" - doAssert runtime.stringCount < 32 - doAssert runtime.stringBytes < 256 diff --git a/tests/tests.nim b/tests/tests.nim index 8d6248f9..0ad97e4e 100644 --- a/tests/tests.nim +++ b/tests/tests.nim @@ -70,7 +70,6 @@ import test_lvd_sim, test_mailboxes, test_chats, - test_scripts, test_metrics, test_stats, test_nav, From dd18d747b68176bbbbe3db2125953f713eca7657 Mon Sep 17 00:00:00 2001 From: treeform Date: Thu, 24 Sep 2026 11:55:05 -0700 Subject: [PATCH 6/6] Give each game its own chat rules --- coworld/tools/test_runtime.nim | 2 +- docs/mailboxes.md | 27 ++++--- examples/call_to_adventure/bots.nim | 20 ++--- examples/light_vs_dark/bots.nim | 7 +- examples/mailboxes/chat.bas | 2 + tests/test_chats.nim | 116 +++++++++++++++++++++------- 6 files changed, 115 insertions(+), 59 deletions(-) diff --git a/coworld/tools/test_runtime.nim b/coworld/tools/test_runtime.nim index 46634994..eb32bbd5 100644 --- a/coworld/tools/test_runtime.nim +++ b/coworld/tools/test_runtime.nim @@ -264,7 +264,7 @@ for (game, count) in Games: for slot in 0 ..< scripts.len: scripts[slot] = """ -sendChat(mailboxSelf(), "CHAT") +sendChat(-2, "CHAT") print pullMailbox$(), mailboxId() """ episode(game, count, scripts, ticks = 3, diff --git a/docs/mailboxes.md b/docs/mailboxes.md index 9090c220..894066fd 100644 --- a/docs/mailboxes.md +++ b/docs/mailboxes.md @@ -7,20 +7,21 @@ unread messages remain until the player pulls them. Each game defines `sendChat` and its BASIC callbacks in its own `bots.nim`. The examples use these routing rules: -| `sendChat(id, text$)` target | Recipients | -| --- | --- | -| `-2` | Everyone, including the sender. | -| `-1` | Teammates, including the sender. | -| Nonnegative | The player with that zero-based roster ID. | - -GotA uses the heroes' red/blue teams. CTA treats the party as one team. -Light vs Dark treats each player as their own team. The game can change its -routing loop to add range, visibility, or other rules. There is no shared -routing policy or routing callback framework. +| Game | Global (`-2`) | Team (`-1`) | DM (player ID) | +| --- | --- | --- | --- | +| Gods of the Arena | Everyone. | Heroes on the sender's red/blue team. | One player. | +| Light vs Dark | Everyone. | Unsupported. | One player. | +| Call to Adventure | Players within 16 tiles on the same level. | Unsupported. | Unsupported. | + +Broadcasts include the sender. CTA uses its existing Chebyshev tile distance: +the difference along each tile axis must be at most 16. Range and level are +checked when sending; moving afterward does not remove queued messages. +Each game owns this routing loop. There is no shared routing policy or +routing callback framework. `sendChat` returns the number of inboxes that accepted a copy. Empty or -oversized messages and invalid destinations return zero. A broadcast can -reach some players even when another player's inbox is full. +oversized messages and unsupported or invalid destinations return zero. +A broadcast can reach some players even when another player's inbox is full. `pullMailbox$()` consumes the oldest message, or returns an empty string if there is none. `mailboxId()` identifies the last message pulled: `-2` for @@ -33,7 +34,9 @@ CTA uses 0 to 3, and Light vs Dark uses 0 to 1. ```basic sendChat(-2, "Hello everyone.") +' GotA only: sendChat(-1, "Meet at the checkpoint.") +' GotA and Light vs Dark: sendChat(0, "A direct message to player zero.") message$ = pullMailbox$() while message$ <> "" diff --git a/examples/call_to_adventure/bots.nim b/examples/call_to_adventure/bots.nim index 7c83711a..aab456a5 100644 --- a/examples/call_to_adventure/bots.nim +++ b/examples/call_to_adventure/bots.nim @@ -116,21 +116,13 @@ proc heroLimits(): Limits = proc sendChat*( game: Game, sender, target: int, text: openArray[char] ): int32 = - ## Routes chat according to this game's player and team rules. - if sender notin 0 ..< game.inboxes.len or - target < -2 or target >= game.inboxes.len: - return 0 - let id = int32(if target < 0: target else: sender) + ## Broadcasts to players within 16 tiles on the sender's level. + if sender notin 0 ..< game.inboxes.len or target != -2: + return 0 + let origin = game.world.actors[sender].home for recipient in 0 ..< game.inboxes.len: - case target - of -2: - discard - of -1: - discard - else: - if recipient != target: - continue - if game.inboxes[recipient].push(id, text): + let distance = tileDistance(origin, game.world.actors[recipient].home) + if distance in 0 .. 16 and game.inboxes[recipient].push(-2, text): inc result proc buildHeroHost(heroId: int32): Host = diff --git a/examples/light_vs_dark/bots.nim b/examples/light_vs_dark/bots.nim index d80e7dea..b111e436 100644 --- a/examples/light_vs_dark/bots.nim +++ b/examples/light_vs_dark/bots.nim @@ -271,18 +271,15 @@ proc observedAt(index: int32): Observed = proc sendChat*( game: Game, sender, target: int, text: openArray[char] ): int32 = - ## Routes chat according to this game's player and team rules. + ## Routes global broadcasts and direct messages between players. if sender notin 0 ..< game.inboxes.len or - target < -2 or target >= game.inboxes.len: + target < -2 or target == -1 or target >= game.inboxes.len: return 0 let id = int32(if target < 0: target else: sender) for recipient in 0 ..< game.inboxes.len: case target of -2: discard - of -1: - if recipient != sender: - continue else: if recipient != target: continue diff --git a/examples/mailboxes/chat.bas b/examples/mailboxes/chat.bas index 6c6ef898..575bec89 100644 --- a/examples/mailboxes/chat.bas +++ b/examples/mailboxes/chat.bas @@ -6,7 +6,9 @@ while message$ <> "" wend if announced = 0 then sendChat(-2, "Hello everyone.") + ' GotA supports team chat. Other games ignore this destination. sendChat(-1, "Hello teammates.") + ' GotA and Light vs Dark support DMs. CTA ignores this destination. sendChat(mailboxSelf(), "A private note to myself.") announced = 1 end if diff --git a/tests/test_chats.nim b/tests/test_chats.nim index ff509b04..65733e0a 100644 --- a/tests/test_chats.nim +++ b/tests/test_chats.nim @@ -20,8 +20,19 @@ message$ = pullMailbox$() from = mailboxId() """ -proc checkMailboxes[T](game: T, teamCount: int) = - ## Checks default chat routing and repeated reads through a game's hosts. +const CtaProgram = """ +rejectedTeam = sendChat(-1, "team") +rejectedDm = sendChat(mailboxSelf(), "direct") +sent = sendChat(-2, "nearby hello") +message$ = pullMailbox$() +from = mailboxId() +while mailboxCount() > 0 + ignored$ = pullMailbox$() +wend +""" + +proc checkMailboxes[T](game: T) = + ## Checks each game's chat routing and repeated reads through BASIC. template send(sender, target, text: untyped): untyped = ## Uses the game's own routing implementation. when T is ctaSim.Game: @@ -30,30 +41,39 @@ proc checkMailboxes[T](game: T, teamCount: int) = gotaBots.sendChat(game, sender, target, text) else: lvdBots.sendChat(game, sender, target, text) - doAssert send(0, -1, "team") == teamCount - var received = 0 - for slot, inbox in game.inboxes: - when T is ctaSim.Game: - let teammate = true - elif T is gotaSim.Game: + when T is gotaSim.Game: + doAssert send(0, -1, "team") == 5 + for slot, inbox in game.inboxes: let teammate = game.world.heroes[slot].team == game.world.heroes[0].team - else: - let teammate = slot == 0 - doAssert inbox.count == int(teammate) - if inbox.count > 0: - inc received - doAssert inbox.messages[inbox.first] == "team" - doAssert inbox.pop() == -1 - doAssert received == teamCount + doAssert inbox.count == int(teammate) + if teammate: + doAssert inbox.messages[inbox.first] == "team" + doAssert inbox.pop() == -1 + else: + doAssert send(0, -1, "team") == 0 + for inbox in game.inboxes: + doAssert inbox.count == 0 doAssert send(0, -2, "global") == game.inboxes.len for inbox in game.inboxes: + doAssert inbox.messages[inbox.first] == "global" doAssert inbox.pop() == -2 - doAssert send(0, 1, "direct") == 1 - doAssert game.inboxes[1].messages[game.inboxes[1].first] == "direct" - doAssert game.inboxes[1].pop() == 0 + when T is ctaSim.Game: + for target in 0 ..< game.inboxes.len: + doAssert send(0, target, "direct") == 0 + for inbox in game.inboxes: + doAssert inbox.count == 0 + else: + doAssert send(0, 1, "direct") == 1 + for slot, inbox in game.inboxes: + doAssert inbox.count == int(slot == 1) + doAssert game.inboxes[1].messages[game.inboxes[1].first] == "direct" + doAssert game.inboxes[1].pop() == 0 + doAssert send(-1, -2, "invalid") == 0 + doAssert send(game.inboxes.len, -2, "invalid") == 0 + doAssert send(0, -3, "invalid") == 0 doAssert send(0, game.inboxes.len, "invalid") == 0 for i in 0 ..< MaxMailboxMessages: - doAssert send(0, 0, "full") == 1 + doAssert game.inboxes[0].push(-2, "full") doAssert send(0, -2, "partial") == game.inboxes.len - 1 doAssert game.inboxes[0].count == MaxMailboxMessages for inbox in game.inboxes: @@ -74,10 +94,18 @@ proc checkMailboxes[T](game: T, teamCount: int) = let vms = game.heroVms for index, vm in vms: doAssert vm != nil and not vm.failed, vm.lastError - doAssert vm.runtime.getGlobal("sent") == 1 - doAssert vm.runtime.getGlobal("from") == index - doAssert vm.runtime.getString(vm.runtime.getGlobalValue("message$")) == - "private hello" + when T is ctaSim.Game: + doAssert vm.runtime.getGlobal("rejectedTeam") == 0 + doAssert vm.runtime.getGlobal("rejectedDm") == 0 + doAssert vm.runtime.getGlobal("sent") == ctaContent.PartySize + doAssert vm.runtime.getGlobal("from") == -2 + doAssert vm.runtime.getString(vm.runtime.getGlobalValue("message$")) == + "nearby hello" + else: + doAssert vm.runtime.getGlobal("sent") == 1 + doAssert vm.runtime.getGlobal("from") == index + doAssert vm.runtime.getString(vm.runtime.getGlobalValue("message$")) == + "private hello" let large = vm.runtime.putString(repeat('x', 1024)) doAssert vm.runtime.getString(large).len == 1024 when defined(nimAllocStats) and T is gotaSim.Game: @@ -91,6 +119,38 @@ proc checkMailboxes[T](game: T, teamCount: int) = let after = getAllocStats() doAssert after == before, $(after - before) +proc checkRange(game: ctaSim.Game) = + ## Checks inclusive tile range, level isolation, and routing at send time. + for inbox in game.inboxes: + while inbox.count > 0: + discard inbox.pop() + for slot in 0 ..< ctaContent.PartySize: + game.world.actors[slot].home.level = 0 + game.world.actors[slot].home.x = 20 + game.world.actors[slot].home.z = 20 + game.world.actors[1].home.x = 36 + game.world.actors[2].home.x = 37 + game.world.actors[3].home.level = 1 + doAssert ctaBots.sendChat(game, 0, -2, "boundary") == 2 + doAssert game.inboxes[0].pop() == -2 + doAssert game.inboxes[2].count == 0 + doAssert game.inboxes[3].count == 0 + game.world.actors[1].home.level = 1 + doAssert game.inboxes[1].messages[game.inboxes[1].first] == "boundary" + doAssert game.inboxes[1].pop() == -2 + doAssert ctaBots.sendChat(game, 0, -2, "different level") == 1 + doAssert game.inboxes[0].pop() == -2 + doAssert game.inboxes[1].count == 0 + game.world.actors[1].home.level = 0 + game.world.actors[1].home.z = 36 + doAssert ctaBots.sendChat(game, 0, -2, "diagonal") == 2 + doAssert game.inboxes[0].pop() == -2 + doAssert game.inboxes[1].pop() == -2 + game.world.actors[1].home.z = 37 + doAssert ctaBots.sendChat(game, 0, -2, "outside") == 1 + doAssert game.inboxes[0].pop() == -2 + doAssert game.inboxes[1].count == 0 + echo "Testing default mailboxes through all three games' BASIC hosts" block: let @@ -110,17 +170,19 @@ block: ) gotaBots.loadBots(gota, [BotGroup(path: path, count: 10)]) echo "Checking GotA" - gota.checkMailboxes(5) + gota.checkMailboxes() echo "GotA passed" let cta = ctaSim.newGame(2026) + writeFile(path, CtaProgram) ctaBots.loadBots(cta, [BotGroup(path: path, count: ctaContent.PartySize)]) echo "Checking CTA" - cta.checkMailboxes(ctaContent.PartySize) + cta.checkMailboxes() + cta.checkRange() echo "CTA passed" let lvd = lvdSim.newGame(lvdMaps.generateMap(lvdContent.DefaultSeed), 240) lvdBots.loadBots(lvd, [Program, Program]) echo "Checking LVD" - lvd.checkMailboxes(1) + lvd.checkMailboxes() echo "LVD passed"