Skip to content
Draft
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ resources
# Environment and IDE
.claude/tasks
.claude/analysis
.memory/
.envrc
.env
.env.*
Expand Down
73 changes: 0 additions & 73 deletions .memory/sessions/2026-01-17.session.jsonl

This file was deleted.

31 changes: 31 additions & 0 deletions packages/cogames/src/cogames/cogs_vs_clips/mission.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
TransferActionConfig,
VibeTransfer,
)
from mettagrid.config.event_config import EventConfig, periodic
from mettagrid.config.filter_config import BelongsToCollective, NearCollective, isNeutral
from mettagrid.config.mettagrid_config import (
AgentConfig,
AgentRewards,
Expand All @@ -44,6 +46,7 @@
MettaGridConfig,
ResourceLimitsConfig,
)
from mettagrid.config.mutation_config import AlignToCollective, RemoveAlignment
from mettagrid.config.vibes import Vibe
from mettagrid.map_builder.map_builder import AnyMapBuilderConfig

Expand Down Expand Up @@ -302,6 +305,12 @@ class CogsGuardMission(Config):
collective_initial_silicon: int = Field(default=10)
collective_initial_heart: int = Field(default=5)

# Clips Behavior
clips_scramble_interval: int = Field(default=50)
clips_scramble_radius: int = Field(default=10)
clips_align_interval: int = Field(default=100)
clips_align_radius: int = Field(default=10)

# Station configs
wall: CvCWallConfig = Field(default_factory=CvCWallConfig)

Expand Down Expand Up @@ -371,6 +380,28 @@ def make_env(self) -> MettaGridConfig:
),
"clips": CollectiveConfig(),
},
events={
"cogs_to_neutral": EventConfig(
name="cogs_to_neutral",
timesteps=periodic(
start=self.clips_scramble_interval,
period=self.clips_scramble_interval,
end=self.max_steps,
),
filters=[NearCollective("clips", radius=self.clips_scramble_radius), BelongsToCollective("cogs")],
mutations=[RemoveAlignment()],
),
"neutral_to_clips": EventConfig(
name="neutral_to_clips",
timesteps=periodic(
start=self.clips_align_interval,
period=self.clips_align_interval,
end=self.max_steps,
),
filters=[NearCollective("clips", radius=self.clips_align_radius), isNeutral()],
mutations=[AlignToCollective("clips")],
),
},
)

env = MettaGridConfig(game=game)
Expand Down
97 changes: 97 additions & 0 deletions packages/cogames/tests/test_cogsguard_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env python3

"""Tests for CogsGuardMission clip takeover events.

These tests verify that CogsGuardMission correctly configures the events
for territorial expansion using NearCollective and alignment mutations.
"""

import pytest

from cogames.cogs_vs_clips.missions import make_cogsguard_mission


class TestCogsGuardMissionEvents:
"""Tests for CogsGuardMission with clip takeover events."""

def test_cogsguard_mission_has_events(self):
"""Test that CogsGuardMission creates events correctly."""
mission = make_cogsguard_mission(num_agents=2, max_steps=500)
env = mission.make_env()

# Verify events are configured
assert len(env.game.events) == 2
assert "cogs_to_neutral" in env.game.events
assert "neutral_to_clips" in env.game.events

# Verify cogs_to_neutral event uses NearCollective and BelongsToCollective
e1 = env.game.events["cogs_to_neutral"]
assert len(e1.filters) == 2
assert e1.filters[0].filter_type == "near_collective"
assert e1.filters[0].collective == "clips"
assert e1.filters[1].filter_type == "collective"
assert e1.filters[1].collective == "cogs"
assert e1.mutations[0].mutation_type == "alignment"

# Verify neutral_to_clips event
e2 = env.game.events["neutral_to_clips"]
assert e2.filters[0].filter_type == "near_collective"
assert e2.filters[0].collective == "clips"
assert e2.mutations[0].mutation_type == "align_to_collective"
assert e2.mutations[0].collective == "clips"

def test_cogsguard_mission_event_timesteps(self):
"""Test that event timesteps are calculated correctly based on max_steps."""
mission = make_cogsguard_mission(num_agents=2, max_steps=300)
env = mission.make_env()

# cogs_to_neutral fires at clips_scramble_interval (default 50): 50, 100, 150, 200, 250, 300
expected_cogs_to_neutral = [50, 100, 150, 200, 250, 300]
# neutral_to_clips fires at clips_align_interval (default 100): 100, 200, 300
expected_neutral_to_clips = [100, 200, 300]
assert env.game.events["cogs_to_neutral"].timesteps == expected_cogs_to_neutral
assert env.game.events["neutral_to_clips"].timesteps == expected_neutral_to_clips

def test_cogsguard_mission_collectives(self):
"""Test that CogsGuardMission has cogs and clips collectives (no neutral)."""
mission = make_cogsguard_mission(num_agents=2, max_steps=500)
env = mission.make_env()

# Should have cogs and clips collectives, but NOT neutral
assert "cogs" in env.game.collectives
assert "clips" in env.game.collectives
assert "neutral" not in env.game.collectives

def test_cogsguard_event_serialization_roundtrip(self):
"""Test that mission events survive serialization/deserialization."""
from mettagrid.config.mettagrid_config import MettaGridConfig

mission = make_cogsguard_mission(num_agents=2, max_steps=200)
env = mission.make_env()

# Serialize and deserialize
json_str = env.model_dump_json()
restored = MettaGridConfig.model_validate_json(json_str)

# Verify events are preserved
assert len(restored.game.events) == 2

# Verify cogs_to_neutral event (scramble_interval=50)
e1 = restored.game.events["cogs_to_neutral"]
assert e1.timesteps == [50, 100, 150, 200]
assert len(e1.filters) == 2
assert e1.filters[0].filter_type == "near_collective"
assert e1.filters[0].collective == "clips"
assert e1.filters[1].filter_type == "collective"
assert e1.filters[1].collective == "cogs"

# Verify neutral_to_clips event (align_interval=100)
e2 = restored.game.events["neutral_to_clips"]
assert e2.timesteps == [100, 200]
assert e2.filters[0].filter_type == "near_collective"
assert e2.filters[0].collective == "clips"
assert e2.filters[0].radius == 10 # default clips_align_radius


if __name__ == "__main__":
pytest.main([__file__, "-v"])
2 changes: 0 additions & 2 deletions packages/mettagrid/.memory/sessions/2026-01-17.session.jsonl

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,51 @@ class CollectiveFilter : public Filter {
CollectiveFilterConfig _config;
};

/**
* NearCollectiveFilter: Check if entity is NOT aligned to a collective but near it
* Passes if target is NOT aligned to the specified collective AND is within radius
* of an object that IS aligned to that collective.
* This is useful for "expansion" mechanics where a collective absorbs nearby neutral/enemy objects.
*/
class NearCollectiveFilter : public Filter {
public:
// Callback type for checking if within radius of a collective
// Takes: GridObject* target, collective name, radius
// Returns: true if target is within radius of any object aligned to that collective
using NearCallback = std::function<bool(GridObject*, const std::string&, int)>;

explicit NearCollectiveFilter(const NearCollectiveFilterConfig& config) : _config(config) {}

// Set the callback for checking proximity (called during filter setup)
void set_near_callback(NearCallback callback) { _is_near_collective = std::move(callback); }

bool passes(const HandlerContext& ctx) const override {
// Get GridObject to access collective
GridObject* grid_obj = dynamic_cast<GridObject*>(ctx.resolve(_config.entity));
if (grid_obj == nullptr) {
return false;
}

// Check if target is NOT aligned to the specified collective
Collective* coll = grid_obj->getCollective();
if (coll != nullptr && coll->name == _config.collective) {
// Target is aligned to this collective, so filter fails
return false;
}

// Check if target is within radius of the collective
if (_is_near_collective) {
return _is_near_collective(grid_obj, _config.collective, _config.radius);
}

// If no near callback is set, filter fails
return false;
}

private:
NearCollectiveFilterConfig _config;
NearCallback _is_near_collective = nullptr;
};
} // namespace mettagrid

#endif // PACKAGES_METTAGRID_CPP_INCLUDE_METTAGRID_HANDLER_FILTERS_FILTER_HPP_
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ inline void bind_handler_config(py::module& m) {
.def_readwrite("entity", &CollectiveFilterConfig::entity)
.def_readwrite("collective_id", &CollectiveFilterConfig::collective_id);

py::class_<NearCollectiveFilterConfig>(m, "NearCollectiveFilterConfig")
.def(py::init<>())
.def_readwrite("entity", &NearCollectiveFilterConfig::entity)
.def_readwrite("collective", &NearCollectiveFilterConfig::collective)
.def_readwrite("radius", &NearCollectiveFilterConfig::radius);

// Mutation configs
py::class_<ResourceDeltaMutationConfig>(m, "ResourceDeltaMutationConfig")
.def(py::init<>())
Expand Down Expand Up @@ -146,6 +152,10 @@ inline void bind_handler_config(py::module& m) {
"add_collective_filter",
[](HandlerConfig& self, const CollectiveFilterConfig& cfg) { self.filters.push_back(cfg); },
py::arg("filter"))
.def(
"add_near_collective_filter",
[](HandlerConfig& self, const NearCollectiveFilterConfig& cfg) { self.filters.push_back(cfg); },
py::arg("filter"))
// Add mutation methods - each type wraps into the variant
.def(
"add_resource_delta_mutation",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,20 @@ struct CollectiveFilterConfig {
int collective_id = -1; // ID (index) of the collective the object must belong to (-1 = unset)
};

struct NearCollectiveFilterConfig {
EntityRef entity = EntityRef::target;
std::string collective; // Collective to check proximity to
int radius = 1; // Radius (chebyshev distance) to check
};

// Variant type for all filter configs
using FilterConfig = std::variant<VibeFilterConfig,
ResourceFilterConfig,
AlignmentFilterConfig,
TagFilterConfig,
ObjectTypeFilterConfig,
CollectiveFilterConfig>;
CollectiveFilterConfig,
NearCollectiveFilterConfig>;

// ============================================================================
// Mutation Configs
Expand Down
2 changes: 2 additions & 0 deletions packages/mettagrid/cpp/src/mettagrid/handler/event.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ std::unique_ptr<Filter> Event::create_filter(const FilterConfig& config) {
return std::make_unique<ObjectTypeFilter>(cfg);
} else if constexpr (std::is_same_v<T, CollectiveFilterConfig>) {
return std::make_unique<CollectiveFilter>(cfg);
} else if constexpr (std::is_same_v<T, NearCollectiveFilterConfig>) {
return std::make_unique<NearCollectiveFilter>(cfg);
} else {
return nullptr;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/mettagrid/cpp/src/mettagrid/handler/handler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ std::unique_ptr<Filter> Handler::create_filter(const FilterConfig& config) {
return std::make_unique<ObjectTypeFilter>(cfg);
} else if constexpr (std::is_same_v<T, CollectiveFilterConfig>) {
return std::make_unique<CollectiveFilter>(cfg);
} else if constexpr (std::is_same_v<T, NearCollectiveFilterConfig>) {
return std::make_unique<NearCollectiveFilter>(cfg);
} else {
return nullptr;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ class EventConfig(Config):
Note:
Filters target GridObjects, not agents. Use ObjectTypeFilter to select
by object type, CollectiveFilter to select by collective membership,
or BoundaryFilter to select objects adjacent to specific collectives.
or NearCollectiveFilter to select objects near specific collectives.
"""

name: str = Field(description="Unique name for this event")
Expand Down
41 changes: 41 additions & 0 deletions packages/mettagrid/python/src/mettagrid/config/filter_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,36 @@ class CollectiveFilter(Filter):
collective: str = Field(description="Name of collective the object must belong to")


class NearCollectiveFilter(Filter):
"""Filter that checks if target is NOT aligned to a collective but near it.

This is useful for "expansion" mechanics where a collective absorbs nearby
neutral or enemy objects. The filter passes if:
- Target is NOT aligned to the specified collective, AND
- Target is within the specified radius of an object that IS aligned to that collective

Example:
NearCollectiveFilter(collective="clips", radius=2)
# Affects objects not aligned to clips that are within 2 tiles of clips territory
"""

filter_type: Literal["near_collective"] = "near_collective"
target: HandlerTarget = Field(
default=HandlerTarget.TARGET,
description="Entity to check the filter against",
)
collective: str = Field(description="Collective to check proximity to (target must NOT belong to this)")
radius: int = Field(default=1, description="Chebyshev distance (square radius) to check for collective presence")


AnyFilter = Annotated[
Union[
Annotated[VibeFilter, Tag("vibe")],
Annotated[ResourceFilter, Tag("resource")],
Annotated[AlignmentFilter, Tag("alignment")],
Annotated[ObjectTypeFilter, Tag("object_type")],
Annotated[CollectiveFilter, Tag("collective")],
Annotated[NearCollectiveFilter, Tag("near_collective")],
],
Discriminator("filter_type"),
]
Expand Down Expand Up @@ -192,6 +215,22 @@ def BelongsToCollective(collective: str) -> CollectiveFilter:
return CollectiveFilter(collective=collective)


# Helper for NearCollectiveFilter
def NearCollective(collective: str, radius: int = 1) -> NearCollectiveFilter:
"""Filter: target is NOT aligned to collective but within radius of it.

This is useful for expansion mechanics where a collective absorbs nearby objects.
The filter passes if:
- Target is NOT aligned to the specified collective, AND
- Target is within radius tiles of an object aligned to that collective

Args:
collective: Name of the collective to check proximity to
radius: Chebyshev distance (square radius) to check for collective presence
"""
return NearCollectiveFilter(collective=collective, radius=radius)


# Re-export all filter-related types
__all__ = [
# Enums
Expand All @@ -205,6 +244,7 @@ def BelongsToCollective(collective: str) -> CollectiveFilter:
"AlignmentFilter",
"ObjectTypeFilter",
"CollectiveFilter",
"NearCollectiveFilter",
"AnyFilter",
# Filter helpers
"isAligned",
Expand All @@ -218,4 +258,5 @@ def BelongsToCollective(collective: str) -> CollectiveFilter:
"TargetCollectiveHas",
"HasTag",
"BelongsToCollective",
"NearCollective",
]
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from mettagrid.mettagrid_c import InventoryConfig as CppInventoryConfig
from mettagrid.mettagrid_c import LimitDef as CppLimitDef
from mettagrid.mettagrid_c import MoveActionConfig as CppMoveActionConfig
from mettagrid.mettagrid_c import NearCollectiveFilterConfig as CppNearCollectiveFilterConfig
from mettagrid.mettagrid_c import ObjectTypeFilterConfig as CppObjectTypeFilterConfig
from mettagrid.mettagrid_c import Protocol as CppProtocol
from mettagrid.mettagrid_c import ResourceDeltaMutationConfig as CppResourceDeltaMutationConfig
Expand Down Expand Up @@ -122,6 +123,14 @@ def _convert_handlers(
cpp_filter.collective_id = collective_name_to_id[collective_name]
cpp_handler.add_collective_filter(cpp_filter)

elif filter_type == "near_collective":
# Near collective filter checks if target is NOT aligned to collective but near it
cpp_filter = CppNearCollectiveFilterConfig()
cpp_filter.entity = convert_entity_ref(filter_config.target)
cpp_filter.collective = filter_config.collective
cpp_filter.radius = filter_config.radius
cpp_handler.add_near_collective_filter(cpp_filter)

# Convert mutations using shared utility
convert_mutations(
handler.mutations,
Expand Down
Loading
Loading