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/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/coworld/tools/test_runtime.nim b/coworld/tools/test_runtime.nim index 50197699..eb32bbd5 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(-2, "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..894066fd --- /dev/null +++ b/docs/mailboxes.md @@ -0,0 +1,58 @@ +# Player mailboxes + +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. + +Each game defines `sendChat` and its BASIC callbacks in its own `bots.nim`. +The examples use these routing rules: + +| 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 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 +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. + +`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.") +' 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$ <> "" + print mailboxId(), message$ + message$ = pullMailbox$() +wend +``` + +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 42356bb0..aab456a5 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/[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 = 256 * 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,55 @@ proc heroLimits(): Limits = result.maxPrintBytes = 4 * 1024 result.maxPrintEvents = 256 +proc sendChat*( + game: Game, sender, target: int, text: openArray[char] +): int32 = + ## 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: + 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 = ## Builds the world-query and high-level action API for one hero. result = initHost() + 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) @@ -225,15 +273,18 @@ proc loadBots*( schema = buildHeroHost(100) kinds = controllerKinds(PartySize, playerSlot) sources = groups.expandBotSources(kinds) + for inbox in game.inboxes.mitems: + inbox = newMailbox() var bound = false for slot in 0 ..< PartySize: if kinds[slot] == PlayerController: continue + 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 @@ -243,7 +294,7 @@ proc loadBots*( buildHeroHost(int32(100 + slot)), limits ), - ready: true + ready: true, ) when defined(coworld): game.heroVms[slot].output = playerPrinter(int(slot)) @@ -260,8 +311,8 @@ proc runBotDecisions*(game: Game, slot: int32) {.measure.} = activeGame = game activeHeroSlot = slot let objective = game.objectiveTile(slot) - game.heroVms[slot].runtime.restart() try: + game.heroVms[slot].runtime.restart() game.heroVms[slot].runtime.setData(heroDataIds[DataSelfId], actor.id) game.heroVms[slot].runtime.setData( heroDataIds[DataSelfClass], @@ -329,4 +380,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..9e7b55c5 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 @@ -41,6 +41,7 @@ type historyPlayback*: bool replayMode*: bool heroVms*: array[PartySize, HeroVm] + 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 5a7e5b55..286851ba 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/[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 = 256 * 1024 result.maxSourceBytes = 64 * 1024 result.maxCodeInstructions = 20_000 result.maxArrays = 32 @@ -259,9 +261,64 @@ proc abilityProc(heroId: int32, field: AbilityField): HostProc = of AbilityRestore: spec.restore of AbilityManaCost: spec.manaCost +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 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: @@ -798,15 +855,19 @@ proc loadBots*( kinds = controllerKinds(game.world.heroes.len, playerSlot) sources = groups.expandBotSources(kinds) game.heroVms.setLen(game.world.heroes.len) + 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 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 @@ -831,8 +892,8 @@ proc runHeroScript(game: Game, index: int) = vm = game.heroVms[index] if vm == nil or vm.failed: return - vm.runtime.restart() try: + 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 e9578b22..0e23a3ad 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 @@ -320,6 +320,7 @@ type replayMode*: bool recordingError*: string heroVms*: seq[HeroVm] + 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 c7ce0985..b111e436 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/[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 = 256 * 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,6 +268,24 @@ proc observedAt(index: int32): Observed = return Observed(owner: -1) snapshot[index] +proc sendChat*( + game: Game, sender, target: int, text: openArray[char] +): int32 = + ## Routes global broadcasts and direct messages between players. + if sender notin 0 ..< game.inboxes.len or + 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 + 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. ## @@ -280,6 +299,40 @@ 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 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) @@ -541,23 +594,28 @@ 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) + 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 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 + ready: true, ) if not bound: bindOverlordData(program) @@ -582,8 +640,8 @@ proc runDecision(game: Game, player: int32) = home = structure.origin break - game.brains[player].runtime.restart() try: + 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 21b888c8..cfb3f898 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 @@ -153,6 +153,7 @@ type historyPlayback*: bool replayMode*: bool brains*: array[PlayerCount, OverlordVm] + inboxes*: array[PlayerCount, Mailbox] mapSeed*: int32 maximumTicks*: int32 diff --git a/examples/mailboxes/chat.bas b/examples/mailboxes/chat.bas new file mode 100644 index 00000000..575bec89 --- /dev/null +++ b/examples/mailboxes/chat.bas @@ -0,0 +1,14 @@ +' Drain all unread messages. Empty text means the queue is empty. +message$ = pullMailbox$() +while message$ <> "" + print mailboxId(), message$ + message$ = pullMailbox$() +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/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/mailboxes.nim b/src/polyworld/mailboxes.nim new file mode 100644 index 00000000..5cd04771 --- /dev/null +++ b/src/polyworld/mailboxes.nim @@ -0,0 +1,38 @@ +const + MaxMailboxMessages* = 100 + MaxChatBytes* = 1024 + NoMailboxId* = -3 + +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 + let index = (mailbox.first + mailbox.count) mod MaxMailboxMessages + mailbox.ids[index] = id + mailbox.messages[index].setLen(text.len) + for i in 0 ..< text.len: + mailbox.messages[index][i] = text[i] + inc mailbox.count + true + +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/tests/test_chats.nim b/tests/test_chats.nim new file mode 100644 index 00000000..65733e0a --- /dev/null +++ b/tests/test_chats.nim @@ -0,0 +1,188 @@ +import + std/[os, strutils, tempfiles], + bassy, + polyworld/[cli, mailboxes] +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") +message$ = pullMailbox$() +from = mailboxId() +""" + +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: + ctaBots.sendChat(game, sender, target, text) + elif T is gotaSim.Game: + gotaBots.sendChat(game, sender, target, text) + else: + lvdBots.sendChat(game, sender, target, text) + 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 + 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 + 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 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: + while inbox.count > 0: + discard inbox.pop() + for tick in 1 .. 300: + game.world.tick = int32(tick) + 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: + lvdBots.runBotDecisions(game) + when T is lvdSim.Game: + let vms = game.brains + else: + let vms = game.heroVms + for index, vm in vms: + doAssert vm != nil and not vm.failed, vm.lastError + 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: + # 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) + +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 + 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)]) + echo "Checking GotA" + 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() + cta.checkRange() + echo "CTA passed" + + let lvd = lvdSim.newGame(lvdMaps.generateMap(lvdContent.DefaultSeed), 240) + lvdBots.loadBots(lvd, [Program, Program]) + echo "Checking LVD" + lvd.checkMailboxes() + 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 new file mode 100644 index 00000000..a3a5b748 --- /dev/null +++ b/tests/test_mailbox_allocations.nim @@ -0,0 +1,24 @@ +import + std/strutils, + polyworld/mailboxes, + test_chats + +when not defined(nimAllocStats): + {.error: "Run this test with -d:nimAllocStats to measure allocations.".} + +echo "Testing inbox storage is reused without heap allocations" +block: + let + inbox = newMailbox() + payload = repeat('x', MaxChatBytes) + 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) diff --git a/tests/test_mailboxes.nim b/tests/test_mailboxes.nim new file mode 100644 index 00000000..7a5d81d1 --- /dev/null +++ b/tests/test_mailboxes.nim @@ -0,0 +1,26 @@ +import + std/strutils, + polyworld/mailboxes + +echo "Testing bounded inbox IDs, strings, overflow, and wraparound" +block: + 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 inbox.push(int32(i), $i) + doAssert not inbox.push(999, "overflow") + for i in 0 ..< 10: + 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 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/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,