Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion coworld/dependencies.lock
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 16 additions & 2 deletions coworld/tools/test_runtime.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -178,14 +179,18 @@ 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)
doAssert fileExists(directory / "status.json"), "completion preceded status"
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
Expand Down Expand Up @@ -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,
Expand Down
58 changes: 58 additions & 0 deletions docs/mailboxes.md
Original file line number Diff line number Diff line change
@@ -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.
64 changes: 57 additions & 7 deletions examples/call_to_adventure/bots.nim
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

import
bassy,
polyworld/[bodies, metrics, cli, controllers, pathing, profiles],
polyworld/[mailboxes, bodies, metrics, cli, controllers,
pathing, profiles],
content,
sim,
replays
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand All @@ -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],
Expand Down Expand Up @@ -329,4 +380,3 @@ proc runBotDecisions*(game: Game, slot: int32) {.measure.} =
)
activeGame = nil
activeHeroSlot = -1

3 changes: 2 additions & 1 deletion examples/call_to_adventure/sim.nim
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import
bassy, fixxy,
polyworld/[bodies, hashes, metrics, pathing, profiles, rngs, tapes,
visions],
visions, mailboxes],
content,
maps,
replays
Expand Down Expand Up @@ -41,6 +41,7 @@ type
historyPlayback*: bool
replayMode*: bool
heroVms*: array[PartySize, HeroVm]
inboxes*: array[PartySize, Mailbox]

const
AggroTiles* = 9'i32
Expand Down
69 changes: 65 additions & 4 deletions examples/gods_of_the_arena/bots.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down
3 changes: 2 additions & 1 deletion examples/gods_of_the_arena/sim.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -320,6 +320,7 @@ type
replayMode*: bool
recordingError*: string
heroVms*: seq[HeroVm]
inboxes*: seq[Mailbox]
nextFootmen: seq[Footman]
nextHeroes: seq[Hero]
collisionUnits: seq[CollisionUnit]
Expand Down
Loading
Loading