aboutsummaryrefslogtreecommitdiff
path: root/core/src
diff options
context:
space:
mode:
Diffstat (limited to 'core/src')
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/AbstractionEngineGridWorld.java36
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java61
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/life/CellState.java29
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/life/LifeInput.java13
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/life/LifeSystem.java71
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/screen/Logo.java53
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java156
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/entity/EntityType.java52
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/entity/EntityTypeGridCollidable.java14
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/entity/PlayerFactory.java27
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/component/GridCollidable.java65
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/component/GridControllable.java10
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/component/GridInputState.java37
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/component/GridMomentum.java17
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/component/GridPosition.java16
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/system/BaseGridIndexSystem.java55
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystem.java113
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/system/GridIndexSystem.java10
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/system/GridMovementSystem.java31
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystem.java31
-rw-r--r--core/src/main/java/coffee/liz/ecs/DAGWorld.java158
-rw-r--r--core/src/main/java/coffee/liz/ecs/math/Mat2.java91
-rw-r--r--core/src/main/java/coffee/liz/ecs/math/Vec2.java65
-rw-r--r--core/src/main/java/coffee/liz/ecs/math/Vec2f.java54
-rw-r--r--core/src/main/java/coffee/liz/ecs/math/Vec2i.java57
-rw-r--r--core/src/main/java/coffee/liz/ecs/model/Component.java8
-rw-r--r--core/src/main/java/coffee/liz/ecs/model/Entity.java105
-rw-r--r--core/src/main/java/coffee/liz/ecs/model/System.java29
-rw-r--r--core/src/main/java/coffee/liz/ecs/model/World.java58
-rw-r--r--core/src/main/java/coffee/liz/lambda/LambdaDriver.java79
-rw-r--r--core/src/main/java/coffee/liz/lambda/ast/Expression.java28
-rw-r--r--core/src/main/java/coffee/liz/lambda/ast/LambdaProgram.java19
-rw-r--r--core/src/main/java/coffee/liz/lambda/ast/Macro.java12
-rw-r--r--core/src/main/java/coffee/liz/lambda/ast/SourceCode.java27
-rw-r--r--core/src/main/java/coffee/liz/lambda/ast/SourceComment.java21
-rw-r--r--core/src/main/java/coffee/liz/lambda/ast/SourceSpan.java24
-rw-r--r--core/src/main/java/coffee/liz/lambda/bind/ExternalBinding.java23
-rw-r--r--core/src/main/java/coffee/liz/lambda/bind/Tick.java22
-rw-r--r--core/src/main/java/coffee/liz/lambda/bind/ToChurch.java49
-rw-r--r--core/src/main/java/coffee/liz/lambda/eval/Environment.java109
-rw-r--r--core/src/main/java/coffee/liz/lambda/eval/EvaluationDepthExceededException.java16
-rw-r--r--core/src/main/java/coffee/liz/lambda/eval/NormalOrderEvaluator.java101
-rw-r--r--core/src/main/java/coffee/liz/lambda/eval/Thunk.java27
-rw-r--r--core/src/main/java/coffee/liz/lambda/eval/Value.java42
-rw-r--r--core/src/main/java/coffee/liz/lambda/format/Formatter.java238
-rw-r--r--core/src/main/java/coffee/liz/lambda/parser/ArrowParser.jjt276
-rw-r--r--core/src/main/java/coffee/liz/lambda/parser/LambdaParser.jjt287
-rw-r--r--core/src/main/resources/log4j2.xml13
-rw-r--r--core/src/test/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystemTest.java188
-rw-r--r--core/src/test/java/coffee/liz/abstractionengine/grid/system/GridIndexSystemTest.java70
-rw-r--r--core/src/test/java/coffee/liz/abstractionengine/grid/system/GridMovementSystemTest.java48
-rw-r--r--core/src/test/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystemTest.java46
-rw-r--r--core/src/test/java/coffee/liz/ecs/DAGWorldTest.java164
-rw-r--r--core/src/test/java/coffee/liz/ecs/model/EntityTest.java97
-rw-r--r--core/src/test/java/coffee/liz/lambda/eval/InterpreterTest.java103
-rw-r--r--core/src/test/java/coffee/liz/lambda/eval/ThunkTest.java38
-rw-r--r--core/src/test/java/coffee/liz/lambda/format/FormatterTest.java53
-rw-r--r--core/src/test/java/coffee/liz/lambda/parser/ParserTest.java178
58 files changed, 3920 insertions, 0 deletions
diff --git a/core/src/main/java/coffee/liz/abstractionengine/AbstractionEngineGridWorld.java b/core/src/main/java/coffee/liz/abstractionengine/AbstractionEngineGridWorld.java
new file mode 100644
index 0000000..c6e7a49
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/AbstractionEngineGridWorld.java
@@ -0,0 +1,36 @@
+package coffee.liz.abstractionengine;
+
+import coffee.liz.abstractionengine.grid.component.GridInputState;
+import coffee.liz.abstractionengine.grid.system.GridCollisionPropagatationSystem;
+import coffee.liz.abstractionengine.grid.system.GridIndexSystem;
+import coffee.liz.abstractionengine.grid.system.GridMovementSystem;
+import coffee.liz.abstractionengine.grid.system.GridPhysicsSystem;
+import coffee.liz.ecs.DAGWorld;
+import coffee.liz.ecs.math.Vec2;
+
+import lombok.extern.log4j.Log4j2;
+
+import java.time.Duration;
+import java.util.Map;
+
+/** Grid world implementation for the abstraction engine. */
+@Log4j2
+public class AbstractionEngineGridWorld extends DAGWorld<GridInputState> {
+ /** Initialize world with systems and player. */
+ public AbstractionEngineGridWorld(final Vec2<Integer> gridDimensions) {
+ super(Map.of(GridMovementSystem.class, new GridMovementSystem(), GridPhysicsSystem.class,
+ new GridPhysicsSystem(), GridIndexSystem.class, new GridIndexSystem(gridDimensions),
+ GridCollisionPropagatationSystem.class, new GridCollisionPropagatationSystem()));
+ }
+
+ /**
+ * Update world with input state. For now our grid world is a "step function",
+ * so we shouldn't (yet) care about the amount of time between steps.
+ *
+ * @param state
+ * the {@link GridInputState}
+ */
+ public void update(final GridInputState state) {
+ update(state, Duration.ZERO);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java b/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java
new file mode 100644
index 0000000..a6cd6e9
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java
@@ -0,0 +1,61 @@
+package coffee.liz.abstractionengine.app;
+
+import coffee.liz.abstractionengine.app.screen.Logo;
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.math.Vec2f;
+import com.badlogic.gdx.Game;
+import com.badlogic.gdx.Gdx;
+import com.badlogic.gdx.graphics.Texture;
+import com.badlogic.gdx.graphics.g2d.BitmapFont;
+import com.badlogic.gdx.graphics.g2d.SpriteBatch;
+import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator;
+import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
+import com.badlogic.gdx.utils.viewport.FitViewport;
+
+public class AbstractionEngineGame extends Game {
+ public static final Vec2<Float> WORLD_SIZE = Vec2f.builder().x(200f).y(200f).build();
+ private static final String RENDERABLE_CHARS = "λ" + FreeTypeFontGenerator.DEFAULT_CHARS;
+
+ public SpriteBatch batch;
+ public BitmapFont font;
+ public FitViewport viewport;
+ public ShapeRenderer shapeRenderer;
+
+ public void create() {
+ viewport = new FitViewport(WORLD_SIZE.getX(), WORLD_SIZE.getY());
+ batch = new SpriteBatch();
+ shapeRenderer = new ShapeRenderer();
+ font = initFont(24);
+
+ this.setScreen(new Logo(this));
+
+ viewport.update(Gdx.graphics.getWidth(), Gdx.graphics.getHeight(), true);
+ }
+
+ public void render() {
+ super.render();
+ }
+
+ public void dispose() {
+ batch.dispose();
+ font.dispose();
+ shapeRenderer.dispose();
+ }
+
+ private BitmapFont initFont(final int size) {
+ final FreeTypeFontGenerator gen = new FreeTypeFontGenerator(
+ Gdx.files.internal("fonts/JetBrainsMonoNerdFont-Regular.ttf"));
+ final FreeTypeFontGenerator.FreeTypeFontParameter params = new FreeTypeFontGenerator.FreeTypeFontParameter();
+ params.characters = RENDERABLE_CHARS;
+ params.size = size;
+
+ final BitmapFont font = gen.generateFont(params);
+ font.setFixedWidthGlyphs(RENDERABLE_CHARS);
+ font.setUseIntegerPositions(false);
+ font.getData().setScale(WORLD_SIZE.getY() / Gdx.graphics.getHeight());
+ font.getRegion().getTexture().setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear);
+
+ gen.dispose();
+ return font;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/life/CellState.java b/core/src/main/java/coffee/liz/abstractionengine/app/life/CellState.java
new file mode 100644
index 0000000..94cfdd8
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/life/CellState.java
@@ -0,0 +1,29 @@
+package coffee.liz.abstractionengine.app.life;
+
+import coffee.liz.ecs.model.Component;
+import lombok.Getter;
+
+@Getter
+public class CellState implements Component {
+ private static final float EPS = 1.0e-4f;
+
+ public static final CellState LIVE = new CellState(1.0f);
+ public static final CellState DEAD = new CellState(0.0f);
+
+ private final float decay;
+
+ public CellState(final float decay) {
+ this.decay = clamp(decay);
+ }
+
+ public boolean isAlive() {
+ return decay >= (1.0f - EPS);
+ }
+
+ private static float clamp(final float value) {
+ if (value <= 0.0f) {
+ return 0.0f;
+ }
+ return Math.min(value, 1.0f);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeInput.java b/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeInput.java
new file mode 100644
index 0000000..2213ecc
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeInput.java
@@ -0,0 +1,13 @@
+package coffee.liz.abstractionengine.app.life;
+
+import coffee.liz.ecs.math.Vec2;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+
+import java.util.Set;
+
+@RequiredArgsConstructor
+@Data
+public class LifeInput {
+ private final Set<Vec2<Integer>> forceAliveCells;
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeSystem.java b/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeSystem.java
new file mode 100644
index 0000000..803fe58
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/life/LifeSystem.java
@@ -0,0 +1,71 @@
+package coffee.liz.abstractionengine.app.life;
+
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.abstractionengine.grid.system.BaseGridIndexSystem;
+import coffee.liz.ecs.math.Mat2;
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.math.Vec2i;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.World;
+
+import java.time.Duration;
+import java.util.Set;
+
+/** Conway's game of life */
+public class LifeSystem extends BaseGridIndexSystem<LifeInput> {
+ public final static int MAX_DECAY_STEPS = 12;
+ private final static float DECAY_STEP = 1.0f / MAX_DECAY_STEPS;
+ private final static Vec2<Integer> CONVOLUTION_RADIUS = Vec2i.builder().x(1).y(1).build();
+
+ private final static Duration BETWEEN_UPDATES = Duration.ofMillis(200);
+
+ private Duration sinceUpdate = Duration.ZERO;
+
+ public LifeSystem(final Vec2<Integer> dimensions) {
+ super(dimensions);
+ }
+
+ @Override
+ public void update(final World<LifeInput> world, final LifeInput state, final Duration dt) {
+ super.update(world, state, dt);
+ state.getForceAliveCells().forEach(cell -> {
+ if (!inBounds(cell)) {
+ return;
+ }
+ final Set<Entity> entities = rows.get(cell.getY()).get(cell.getX());
+ entities.forEach(e -> e.add(CellState.LIVE));
+ });
+
+ sinceUpdate = sinceUpdate.plus(dt);
+ if (sinceUpdate.compareTo(BETWEEN_UPDATES) < 0) {
+ return;
+ }
+ sinceUpdate = Duration.ZERO;
+
+ Mat2.convolve(this.rows, CONVOLUTION_RADIUS, () -> 0, (entities, rel, prev) -> {
+ if (rel.equals(Vec2i.ZERO)) {
+ return prev;
+ }
+ return entities.stream().findFirst().map(entity -> entity.get(CellState.class))
+ .map(cellState -> prev + (cellState.isAlive() ? 1 : 0)).orElse(prev);
+ }, (entities, neighboringAliveCells) -> entities.stream().findFirst().map(entity -> {
+ final CellState cellState = entity.get(CellState.class);
+ final float decay = cellState.getDecay();
+
+ final boolean alive = cellState.isAlive();
+ final boolean diesNow = alive && (neighboringAliveCells < 2 || neighboringAliveCells > 3);
+ final boolean spawnsNow = !alive && neighboringAliveCells == 3;
+ final boolean stillAlive = alive && !diesNow;
+
+ final CellState nextState;
+ if (spawnsNow || stillAlive) {
+ nextState = CellState.LIVE;
+ } else {
+ nextState = new CellState(decay - DECAY_STEP);
+ }
+
+ entity.add(nextState);
+ return entity;
+ }));
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/screen/Logo.java b/core/src/main/java/coffee/liz/abstractionengine/app/screen/Logo.java
new file mode 100644
index 0000000..82b637a
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/screen/Logo.java
@@ -0,0 +1,53 @@
+package coffee.liz.abstractionengine.app.screen;
+
+import coffee.liz.abstractionengine.app.AbstractionEngineGame;
+import com.badlogic.gdx.Screen;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+@RequiredArgsConstructor
+@Log4j2
+public class Logo implements Screen {
+ private final AbstractionEngineGame game;
+ float secondsShown = 0;
+
+ @Override
+ public void show() {
+ }
+
+ @Override
+ public void render(final float delta) {
+ secondsShown += delta;
+ if (secondsShown < 2f) {
+ return;
+ }
+
+ log.info("Transition to main menu after {}", secondsShown);
+ game.setScreen(new MainMenu(game));
+ dispose();
+ }
+
+ @Override
+ public void resize(int width, int height) {
+
+ }
+
+ @Override
+ public void pause() {
+
+ }
+
+ @Override
+ public void resume() {
+ }
+
+ @Override
+ public void hide() {
+
+ }
+
+ @Override
+ public void dispose() {
+
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java b/core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java
new file mode 100644
index 0000000..e02acf4
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java
@@ -0,0 +1,156 @@
+package coffee.liz.abstractionengine.app.screen;
+
+import coffee.liz.abstractionengine.app.AbstractionEngineGame;
+import coffee.liz.abstractionengine.app.life.CellState;
+import coffee.liz.abstractionengine.app.life.LifeInput;
+import coffee.liz.abstractionengine.app.life.LifeSystem;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.DAGWorld;
+import coffee.liz.ecs.math.Mat2;
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.math.Vec2f;
+import coffee.liz.ecs.math.Vec2i;
+import coffee.liz.ecs.model.World;
+import com.badlogic.gdx.Gdx;
+import com.badlogic.gdx.Screen;
+import com.badlogic.gdx.graphics.Color;
+import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
+import com.badlogic.gdx.math.Vector3;
+import com.badlogic.gdx.utils.ScreenUtils;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+import java.time.Duration;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static com.badlogic.gdx.math.MathUtils.clamp;
+
+@Log4j2
+@RequiredArgsConstructor
+public class MainMenu implements Screen {
+ private static final Vec2<Integer> DIMENSIONS = Vec2i.builder().x(50).y(50).build();
+ private final World<LifeInput> world = new DAGWorld<>(Map.of(LifeSystem.class, new LifeSystem(DIMENSIONS)));
+ private final Color cellColor = new Color();
+
+ private final AbstractionEngineGame game;
+
+ @Override
+ public void show() {
+ final Set<Vec2<Integer>> glider = Set.of(Vec2i.builder().x(1).y(0).build(), Vec2i.builder().x(2).y(1).build(),
+ Vec2i.builder().x(0).y(2).build(), Vec2i.builder().x(1).y(2).build(),
+ Vec2i.builder().x(2).y(2).build());
+ Mat2.init(DIMENSIONS, pos -> world.createEntity().add(new GridPosition(pos))
+ .add(glider.contains(pos) ? CellState.LIVE : CellState.DEAD));
+ }
+
+ @Override
+ public void render(final float delta) {
+ game.viewport.apply();
+
+ final Vec2<Float> cellSize = Vec2f.builder().x(game.viewport.getWorldWidth() / DIMENSIONS.getX())
+ .y(game.viewport.getWorldHeight() / DIMENSIONS.getY()).build();
+ final Set<Vec2<Integer>> forcedAlive = computeForcedAlive(cellSize);
+
+ world.update(new LifeInput(forcedAlive), Duration.ofMillis((int) (delta * 1000)));
+ ScreenUtils.clear(Color.CLEAR);
+
+ drawCells(cellSize);
+
+ // game.batch.setProjectionMatrix(game.viewport.getCamera().combined);
+ //
+ // for (int i = 0; i < 200; i++) {
+ // game.shapeRenderer.rect(i, i, 1, 1);
+ // }
+ // game.shapeRenderer.end();
+ //
+ //
+ // game.batch.begin();
+ // game.font.setColor(Color.WHITE);
+ // game.font.draw(game.batch, "Welcome to Drop!!! ", 1, 50);
+ // game.font.draw(game.batch, "λλλTap anywhere to begin!", 1, 100);
+ // game.batch.end();
+ //
+ // // world.update(new LifeInput(List.of()), Duration.ofMillis((long)(delta *
+ // // 1_000)));
+ // //
+ // // world.query(Set.of(GridPosition.class,
+ // StepsSinceLive.class)).forEach(entity
+ // // -> {
+ // // final GridPosition gridPosition = entity.get(GridPosition.class);
+ // // final StepsSinceLive stepsSinceLive = entity.get(StepsSinceLive.class);
+ // //
+ // //
+ // // });
+ }
+
+ private Set<Vec2<Integer>> computeForcedAlive(final Vec2<Float> cellSize) {
+ final Vector3 cursorPosition = game.viewport.unproject(new Vector3(Gdx.input.getX(), Gdx.input.getY(), 0));
+ final Vec2<Float> cursorWorld = Vec2f.builder().x(cursorPosition.x).y(cursorPosition.y).build();
+ final float clampedX = clamp(cursorWorld.getX(), 0.0f, game.viewport.getWorldWidth() - 0.001f);
+ final float clampedY = clamp(cursorWorld.getY(), 0.0f, game.viewport.getWorldHeight() - 0.001f);
+ final Vec2<Integer> gridPosition = Vec2i.builder().x((int) (clampedX / cellSize.getX()))
+ .y((int) (clampedY / cellSize.getY())).build();
+ final Set<Vec2<Integer>> forcedAlive = new HashSet<>();
+ if (Gdx.input.isTouched()) {
+ Stream.of(gridPosition).flatMap(
+ pos -> Stream.of(Vec2i.ZERO, Vec2i.EAST, Vec2i.WEST, Vec2i.NORTH, Vec2i.SOUTH).map(pos::plus))
+ .forEach(forcedAlive::add);
+ }
+ return forcedAlive;
+ }
+
+ private void drawCells(final Vec2<Float> cellSize) {
+ game.shapeRenderer.begin(ShapeRenderer.ShapeType.Filled);
+ game.shapeRenderer.setProjectionMatrix(game.viewport.getCamera().combined);
+ world.query(Set.of(GridPosition.class, CellState.class)).forEach(entity -> {
+ final CellState state = entity.get(CellState.class);
+ final Vec2<Integer> gridPos = entity.get(GridPosition.class).getPosition();
+ final Vec2<Float> worldPos = Vec2f.builder().x(gridPos.getX() * cellSize.getX())
+ .y(gridPos.getY() * cellSize.getY()).build();
+
+ final float decay = state.getDecay();
+ if (decay <= 0.0f) {
+ return;
+ }
+ game.shapeRenderer.setColor(interpolateColor(decay));
+ game.shapeRenderer.rect(worldPos.getX(), worldPos.getY(), cellSize.getX(), cellSize.getY());
+ });
+ game.shapeRenderer.end();
+ }
+
+ private Color interpolateColor(final float decay) {
+ final float bright = decay * decay * decay;
+ final float r = 0.02f + (0.98f * bright);
+ final float g = 0.02f + (0.98f * bright);
+ final float b = 0.02f + (0.98f * bright);
+ return cellColor.set(r, g, b, 1.0f);
+ }
+
+ @Override
+ public void resize(final int width, final int height) {
+ game.viewport.update(width, height, true);
+ }
+
+ @Override
+ public void pause() {
+
+ }
+
+ @Override
+ public void resume() {
+
+ }
+
+ @Override
+ public void hide() {
+
+ }
+
+ @Override
+ public void dispose() {
+ world.query(Set.of()).forEach(world::removeEntity);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/entity/EntityType.java b/core/src/main/java/coffee/liz/abstractionengine/entity/EntityType.java
new file mode 100644
index 0000000..e0bf243
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/entity/EntityType.java
@@ -0,0 +1,52 @@
+package coffee.liz.abstractionengine.entity;
+
+import coffee.liz.abstractionengine.grid.component.GridCollidable.CollisionBehavior;
+import coffee.liz.abstractionengine.grid.component.GridCollidable.CollisionBehavior.CollisionBehaviorType;
+import coffee.liz.ecs.model.Component;
+
+/**
+ * Entity type in the
+ * {@link coffee.liz.abstractionengine.AbstractionEngineGridWorld}
+ */
+public enum EntityType implements Component {
+ /** Player entity */
+ PLAYER,
+ /** Wall entity. Nothing goes through this */
+ WALL,
+ /** Lambda term producer */
+ PRODUCER,
+ /** Lambda abstraction */
+ ABSTRACTION,
+ /** Lambda application */
+ APPLICATION,
+ /** Lava */
+ LAVA,
+ /** Bridge */
+ BRIDGE;
+
+ /**
+ * Gets the collision behavior to apply based on the colliding types.
+ *
+ * @param that
+ * is the {@link EntityType} colliding.
+ * @return collision behavior to resolve.
+ */
+ public CollisionBehavior collideWith(final EntityType that) {
+ if (that.equals(WALL)) {
+ return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.WALL).priority(0).build();
+ }
+ if (this.equals(ABSTRACTION) && that.equals(APPLICATION)) {
+ return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.SWALLOW).priority(0).build();
+ }
+
+ if (that.equals(LAVA)) {
+ return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.SWALLOW).priority(1).build();
+ }
+ if (that.equals(BRIDGE)) { // BRIDGE takes precedence over LAVA, so stuff can walk on a bridge over
+ // LAVA
+ return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.SWALLOW).priority(0).build();
+ }
+
+ return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.PROPAGATE).priority(0).build();
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/entity/EntityTypeGridCollidable.java b/core/src/main/java/coffee/liz/abstractionengine/entity/EntityTypeGridCollidable.java
new file mode 100644
index 0000000..c75b717
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/entity/EntityTypeGridCollidable.java
@@ -0,0 +1,14 @@
+package coffee.liz.abstractionengine.entity;
+
+import coffee.liz.abstractionengine.grid.component.GridCollidable;
+import coffee.liz.ecs.model.Entity;
+
+import lombok.Data;
+
+@Data
+public class EntityTypeGridCollidable implements GridCollidable {
+ @Override
+ public CollisionBehavior getCollisionBehaviorBetween(final Entity me, final Entity them) {
+ return me.get(EntityType.class).collideWith(them.get(EntityType.class));
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/entity/PlayerFactory.java b/core/src/main/java/coffee/liz/abstractionengine/entity/PlayerFactory.java
new file mode 100644
index 0000000..240a94e
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/entity/PlayerFactory.java
@@ -0,0 +1,27 @@
+package coffee.liz.abstractionengine.entity;
+
+import coffee.liz.abstractionengine.grid.component.GridControllable;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.World;
+
+/** Factory for creating player {@link Entity}s. */
+public final class PlayerFactory {
+ private PlayerFactory() {
+ }
+
+ /**
+ * Create a player entity.
+ *
+ * @param world
+ * the {@link World} to create in
+ * @param position
+ * the starting {@link Vec2} position
+ * @return created player {@link Entity}
+ */
+ public static Entity addToWorld(final World<?> world, final Vec2<Integer> position) {
+ return world.createEntity().add(new GridPosition(position)).add(new GridControllable()).add(EntityType.PLAYER)
+ .add(new EntityTypeGridCollidable());
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridCollidable.java b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridCollidable.java
new file mode 100644
index 0000000..e72d4e8
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridCollidable.java
@@ -0,0 +1,65 @@
+package coffee.liz.abstractionengine.grid.component;
+
+import coffee.liz.ecs.model.Component;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.World;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+
+/** {@link Component} for grid collision handling. */
+public interface GridCollidable extends Component {
+ @RequiredArgsConstructor
+ @Data
+ @Builder
+ class CollisionBehavior implements Comparable<CollisionBehavior> {
+ private final CollisionBehaviorType collisionBehaviorType;
+ private final int priority;
+
+ public int compareTo(final CollisionBehavior other) {
+ return Integer.compare(getPriority(), other.getPriority());
+ }
+
+ public enum CollisionBehaviorType {
+ /** Propagate collision to next entity. */
+ PROPAGATE,
+ /** Block collision like a wall. */
+ WALL,
+ /** Swallow the colliding entity. */
+ SWALLOW;
+ }
+ }
+
+ /**
+ * Get collision behavior for colliding entity.
+ *
+ * @param me
+ * the staring colliding {@link Entity}
+ * @param them
+ * the colliding {@link Entity}
+ * @return the {@link CollisionBehavior}
+ */
+ CollisionBehavior getCollisionBehaviorBetween(final Entity me, final Entity them);
+
+ /**
+ * Handle swallowing an entity.
+ *
+ * @param them
+ * the {@link Entity} being swallowed
+ * @param world
+ * the {@link World} to modify
+ */
+ default <T> void onSwallow(final Entity them, final World<T> world) {
+ throw new UnsupportedOperationException("Does not swallow"); // ...could not be me~ (つ﹏⊂)
+ }
+
+ /**
+ * Anything that implements GridCollidable should be keyed by GridCollidable.
+ *
+ * @return Key type
+ */
+ default Class<? extends Component> getKey() {
+ return GridCollidable.class;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridControllable.java b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridControllable.java
new file mode 100644
index 0000000..b4a9ba9
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridControllable.java
@@ -0,0 +1,10 @@
+package coffee.liz.abstractionengine.grid.component;
+
+import coffee.liz.ecs.model.Component;
+
+import lombok.Data;
+
+/** {@link Component} marking an entity as player-controllable. */
+@Data
+public class GridControllable implements Component {
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridInputState.java b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridInputState.java
new file mode 100644
index 0000000..8fadb09
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridInputState.java
@@ -0,0 +1,37 @@
+package coffee.liz.abstractionengine.grid.component;
+
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.model.Entity;
+
+import coffee.liz.lambda.ast.LambdaProgram;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import jakarta.annotation.Nullable;
+
+/** {@link coffee.liz.ecs.model.Component} for player input state. */
+@Getter
+@AllArgsConstructor
+@Builder
+public class GridInputState {
+ /** Movement {@link Vec2} direction. */
+ @Nullable
+ private final Vec2<Integer> movement;
+
+ /** Lambda term update to apply. */
+ @Nullable
+ private final UpdateLambdaTerm updateLambdaTerm;
+
+ /** Update lambda term with target entity. */
+ @Getter
+ @RequiredArgsConstructor
+ public static class UpdateLambdaTerm {
+ /** {@link Entity} to update. */
+ private final Entity toUpdate;
+
+ /** {@link LambdaProgram} to apply. */
+ private final LambdaProgram lambdaProgram;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridMomentum.java b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridMomentum.java
new file mode 100644
index 0000000..aec26b1
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridMomentum.java
@@ -0,0 +1,17 @@
+package coffee.liz.abstractionengine.grid.component;
+
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.model.Component;
+
+import lombok.Data;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/** {@link Component} for grid momentum/velocity. */
+@RequiredArgsConstructor
+@Data
+@Getter
+public class GridMomentum implements Component {
+ /** Velocity {@link Vec2}. */
+ private final Vec2<Integer> velocity;
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridPosition.java b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridPosition.java
new file mode 100644
index 0000000..4ec0557
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/component/GridPosition.java
@@ -0,0 +1,16 @@
+package coffee.liz.abstractionengine.grid.component;
+
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.model.Component;
+
+import lombok.Data;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/** {@link Component} for grid position. */
+@RequiredArgsConstructor
+@Data
+@Getter
+public class GridPosition implements Component {
+ private final Vec2<Integer> position;
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/system/BaseGridIndexSystem.java b/core/src/main/java/coffee/liz/abstractionengine/grid/system/BaseGridIndexSystem.java
new file mode 100644
index 0000000..61044f6
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/system/BaseGridIndexSystem.java
@@ -0,0 +1,55 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.math.Mat2;
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.System;
+import coffee.liz.ecs.model.World;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** System which maintains a lookup of the grid. */
+@RequiredArgsConstructor
+@Getter
+public abstract class BaseGridIndexSystem<T> implements System<T> {
+ protected final Vec2<Integer> dimensions;
+ protected final List<List<Set<Entity>>> rows;
+
+ public BaseGridIndexSystem(final Vec2<Integer> dimensions) {
+ this.dimensions = dimensions;
+ this.rows = Mat2.init(dimensions, pos -> new HashSet<>());
+ }
+
+ @Override
+ public Collection<Class<? extends System<T>>> getDependencies() {
+ return Set.of();
+ }
+
+ @Override
+ public void update(final World<T> world, final T state, final Duration dt) {
+ rows.forEach(row -> row.forEach(Set::clear));
+ world.query(Set.of(GridPosition.class)).forEach(entity -> {
+ final GridPosition position = entity.get(GridPosition.class);
+ rows.get(position.getPosition().getY()).get(position.getPosition().getX()).add(entity);
+ });
+ }
+
+ public Collection<Entity> entitiesAt(final Vec2<Integer> pos) {
+ if (!inBounds(pos)) {
+ return Collections.emptySet();
+ }
+ return Set.copyOf(rows.get(pos.getY()).get(pos.getX()));
+ }
+
+ public boolean inBounds(final Vec2<Integer> pos) {
+ return pos.getX() >= 0 && pos.getX() < dimensions.getX() && pos.getY() >= 0 && pos.getY() < dimensions.getY();
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystem.java b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystem.java
new file mode 100644
index 0000000..9d07b59
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystem.java
@@ -0,0 +1,113 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import coffee.liz.abstractionengine.grid.component.GridCollidable;
+import coffee.liz.abstractionengine.grid.component.GridCollidable.CollisionBehavior;
+import coffee.liz.abstractionengine.grid.component.GridInputState;
+import coffee.liz.abstractionengine.grid.component.GridMomentum;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.math.Vec2i;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.System;
+import coffee.liz.ecs.model.World;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * System which resolves collisions between cells in the Grid.
+ */
+public class GridCollisionPropagatationSystem implements System<GridInputState> {
+ @Override
+ public Collection<Class<? extends System<GridInputState>>> getDependencies() {
+ return Set.of(GridMovementSystem.class, GridIndexSystem.class);
+ }
+
+ @Override
+ public void update(final World<GridInputState> world, final GridInputState state, final Duration dt) {
+ final GridIndexSystem indexSystem = world.getSystem(GridIndexSystem.class);
+ world.query(Set.of(GridMomentum.class, GridPosition.class, GridCollidable.class)).forEach(pusher -> {
+ final Vec2<Integer> velocity = pusher.get(GridMomentum.class).getVelocity();
+ if (velocity.equals(Vec2i.ZERO)) {
+ return;
+ }
+
+ final Vec2<Integer> position = pusher.get(GridPosition.class).getPosition();
+ final CollisionRayResult result = resolveCollisionRay(world, pusher, position, velocity);
+
+ pusher.add(new GridMomentum(Vec2i.ZERO));
+ if (result instanceof CollisionRayResult.Blocked) {
+ return;
+ }
+
+ final CollisionRayResult.Propagated propagated = (CollisionRayResult.Propagated) result;
+ propagated.ray().forEach(e -> e.add(new GridMomentum(propagated.direction())));
+ propagated.swallows()
+ .forEach(entry -> entry.getKey().get(GridCollidable.class).onSwallow(entry.getValue(), world));
+ });
+ }
+
+ private CollisionRayResult resolveCollisionRay(World<GridInputState> world, Entity pusher, Vec2<Integer> start,
+ Vec2<Integer> direction) {
+ final GridIndexSystem gridIndex = world.getSystem(GridIndexSystem.class);
+
+ final List<List<Entity>> ray = new ArrayList<>();
+ ray.add(List.of(pusher));
+ final Set<Map.Entry<Entity, Entity>> swallows = new HashSet<>();
+
+ Vec2<Integer> gridPosition = start.plus(direction);
+ while (!ray.getLast().isEmpty()) {
+ if (!gridIndex.inBounds(gridPosition)) {
+ return new CollisionRayResult.Blocked();
+ }
+
+ final List<Entity> collidables = gridIndex.entitiesAt(gridPosition).stream()
+ .filter(e -> e.has(GridCollidable.class)).toList();
+ if (collidables.isEmpty()) {
+ break;
+ }
+
+ final List<Entity> nextRay = new ArrayList<>();
+
+ for (final Entity push : ray.getLast()) {
+ final Map.Entry<Entity, CollisionBehavior> behavior = collidables.stream()
+ .map(c -> Map.entry(c, c.get(GridCollidable.class).getCollisionBehaviorBetween(push, c)))
+ .min(Comparator.comparing(e -> e.getValue().getPriority())).orElseThrow();
+
+ switch (behavior.getValue().getCollisionBehaviorType()) {
+ case PROPAGATE -> {
+ nextRay.add(behavior.getKey());
+ }
+ case SWALLOW -> {
+ swallows.add(Map.entry(behavior.getKey(), push));
+ }
+ case WALL -> {
+ return new CollisionRayResult.Blocked();
+ }
+ }
+ }
+
+ ray.add(nextRay);
+ gridPosition = gridPosition.plus(direction);
+ }
+
+ final Collection<Entity> rayEntities = ray.stream().flatMap(Collection::stream).toList();
+ return new CollisionRayResult.Propagated(rayEntities, swallows, direction);
+ }
+
+ private sealed interface CollisionRayResult permits CollisionRayResult.Propagated, CollisionRayResult.Blocked {
+
+ record Propagated(Collection<Entity> ray, Set<Map.Entry<Entity, Entity>> swallows,
+ Vec2<Integer> direction) implements CollisionRayResult {
+ }
+
+ record Blocked() implements CollisionRayResult {
+ }
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridIndexSystem.java b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridIndexSystem.java
new file mode 100644
index 0000000..ca2279f
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridIndexSystem.java
@@ -0,0 +1,10 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import coffee.liz.abstractionengine.grid.component.GridInputState;
+import coffee.liz.ecs.math.Vec2;
+
+public class GridIndexSystem extends BaseGridIndexSystem<GridInputState> {
+ public GridIndexSystem(final Vec2<Integer> dimensions) {
+ super(dimensions);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridMovementSystem.java b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridMovementSystem.java
new file mode 100644
index 0000000..f5f1089
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridMovementSystem.java
@@ -0,0 +1,31 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import coffee.liz.abstractionengine.grid.component.GridControllable;
+import coffee.liz.abstractionengine.grid.component.GridInputState;
+import coffee.liz.abstractionengine.grid.component.GridMomentum;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.model.System;
+import coffee.liz.ecs.model.World;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Set;
+
+/** System handling player movement input. */
+@RequiredArgsConstructor
+@Getter
+public class GridMovementSystem implements System<GridInputState> {
+ @Override
+ public Collection<Class<? extends System<GridInputState>>> getDependencies() {
+ return Set.of();
+ }
+
+ @Override
+ public void update(final World<GridInputState> world, final GridInputState state, final Duration dt) {
+ world.query(Set.of(GridControllable.class, GridPosition.class))
+ .forEach(entity -> entity.add(new GridMomentum(state.getMovement())));
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystem.java b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystem.java
new file mode 100644
index 0000000..88f8d57
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystem.java
@@ -0,0 +1,31 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import coffee.liz.abstractionengine.grid.component.GridInputState;
+import coffee.liz.abstractionengine.grid.component.GridMomentum;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.math.Vec2i;
+import coffee.liz.ecs.model.System;
+import coffee.liz.ecs.model.World;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Set;
+
+/** System applying physics/position updates from momentum. */
+public class GridPhysicsSystem implements System<GridInputState> {
+ @Override
+ public Collection<Class<? extends System<GridInputState>>> getDependencies() {
+ return Set.of(GridCollisionPropagatationSystem.class);
+ }
+
+ @Override
+ public void update(final World<GridInputState> world, final GridInputState state, final Duration dt) {
+ world.query(Set.of(GridMomentum.class, GridPosition.class)).forEach(entity -> {
+ final GridMomentum momentum = entity.get(GridMomentum.class);
+ final GridPosition position = entity.get(GridPosition.class);
+
+ entity.add(new GridPosition(position.getPosition().plus(momentum.getVelocity())));
+ entity.add(new GridMomentum(Vec2i.ZERO));
+ });
+ }
+}
diff --git a/core/src/main/java/coffee/liz/ecs/DAGWorld.java b/core/src/main/java/coffee/liz/ecs/DAGWorld.java
new file mode 100644
index 0000000..716808a
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/DAGWorld.java
@@ -0,0 +1,158 @@
+package coffee.liz.ecs;
+
+import coffee.liz.ecs.model.Component;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.System;
+import coffee.liz.ecs.model.World;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+/** World that updates in {@link System#getDependencies()} topological order. */
+@Log4j2
+@RequiredArgsConstructor
+public class DAGWorld<T> implements World<T> {
+ /** All entities in the world. */
+ protected final Set<Entity> entities = Collections.synchronizedSet(new HashSet<>());
+
+ /** Cache mapping component types to entities having that component. */
+ private final Map<Class<? extends Component>, Set<Entity>> componentCache = Collections
+ .synchronizedMap(new HashMap<>());
+
+ /** Deterministic ID's for spawned entities. */
+ private final AtomicInteger nextEntityId = new AtomicInteger(0);
+
+ /** All registered systems. */
+ protected final Map<Class<? extends System<T>>, System<T>> systems;
+
+ /** Ordered list of systems for execution. */
+ private final List<System<T>> systemExecutionOrder;
+
+ public DAGWorld(final Map<Class<? extends System<T>>, System<T>> systems) {
+ this.systems = systems;
+ this.systemExecutionOrder = buildExecutionOrder(systems.values().stream().toList());
+ log.debug("Executing in order: {}", systemExecutionOrder);
+ }
+
+ @Override
+ public Entity createEntity() {
+ final Entity entity = Entity.builder().id(nextEntityId.incrementAndGet()).build();
+ entities.add(entity);
+ return entity;
+ }
+
+ @Override
+ public void removeEntity(final Entity entity) {
+ entity.getComponentMap().keySet().forEach(componentType -> {
+ final Set<Entity> cachedEntities = componentCache.get(componentType);
+ if (cachedEntities != null) {
+ cachedEntities.remove(entity);
+ }
+ });
+ entities.remove(entity);
+ }
+
+ @Override
+ public Set<Entity> query(final Collection<Class<? extends Component>> components) {
+ if (components.isEmpty()) {
+ return Set.copyOf(entities);
+ }
+
+ final Class<? extends Component> firstType = components.iterator().next();
+ final Set<Entity> candidates = componentCache.get(firstType);
+ if (candidates == null) {
+ return Collections.emptySet();
+ }
+
+ return candidates.stream().filter(entity -> components.stream().allMatch(entity::has))
+ .collect(Collectors.toSet());
+ }
+
+ @Override
+ public void update(final T state, final Duration duration) {
+ refreshComponentCache();
+ systemExecutionOrder.forEach(system -> {
+ system.update(this, state, duration);
+ });
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public <S extends System<T>> S getSystem(final Class<S> system) {
+ return (S) systems.get(system);
+ }
+
+ private void refreshComponentCache() {
+ componentCache.clear();
+ entities.forEach(entity -> {
+ entity.getComponentMap().keySet().forEach(componentType -> {
+ componentCache.computeIfAbsent(componentType, k -> new HashSet<>()).add(entity);
+ });
+ });
+ }
+
+ private List<System<T>> buildExecutionOrder(final Collection<System<T>> systems) {
+ if (systems.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ final Map<Class<?>, System<T>> systemMap = systems.stream()
+ .collect(Collectors.toMap(System::getClass, system -> system));
+ final Map<Class<?>, Integer> inDegree = new HashMap<>();
+ final Map<Class<?>, Set<Class<?>>> adjacencyList = new HashMap<>();
+
+ systems.forEach(system -> {
+ final Class<?> systemClass = system.getClass();
+ inDegree.put(systemClass, 0);
+ adjacencyList.put(systemClass, new HashSet<>());
+ });
+
+ systems.forEach(system -> {
+ system.getDependencies().forEach(dependency -> {
+ if (systemMap.containsKey(dependency)) {
+ adjacencyList.get(dependency).add(system.getClass());
+ inDegree.merge(system.getClass(), 1, Integer::sum);
+ }
+ });
+ });
+
+ // Kahn's algorithm
+ final List<System<T>> result = new ArrayList<>();
+
+ final Queue<Class<?>> queue = new LinkedList<>(
+ inDegree.entrySet().stream().filter(entry -> entry.getValue() == 0).map(Map.Entry::getKey).toList());
+
+ while (!queue.isEmpty()) {
+ final Class<?> currentClass = queue.poll();
+ result.add(systemMap.get(currentClass));
+
+ adjacencyList.getOrDefault(currentClass, Collections.emptySet()).forEach(dependent -> {
+ final int newInDegree = inDegree.get(dependent) - 1;
+ inDegree.put(dependent, newInDegree);
+ if (newInDegree == 0) {
+ queue.add(dependent);
+ }
+ });
+ }
+
+ if (result.size() != systems.size()) {
+ throw new IllegalStateException("Circular dependency detected in systems");
+ }
+
+ return Collections.unmodifiableList(result);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/ecs/math/Mat2.java b/core/src/main/java/coffee/liz/ecs/math/Mat2.java
new file mode 100644
index 0000000..8be945c
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/math/Mat2.java
@@ -0,0 +1,91 @@
+package coffee.liz.ecs.math;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+public final class Mat2 {
+ private Mat2() {
+ }
+
+ /**
+ * Initializes a mutable 2d matrix of given type.
+ *
+ * @param dimensions
+ * the dimensions
+ * @param constructor
+ * the constructor
+ * @return row-indexed 2d matrix of {@param dimensions}
+ */
+ public static <T> List<List<T>> init(final Vec2<Integer> dimensions, final Function<Vec2<Integer>, T> constructor) {
+ final List<List<T>> rows = new ArrayList<>();
+ for (int y = 0; y < dimensions.getY(); y++) {
+ final List<T> row = new ArrayList<>(dimensions.getX());
+ for (int x = 0; x < dimensions.getX(); x++)
+ row.add(constructor.apply(Vec2i.builder().y(y).x(x).build()));
+ rows.add(row);
+ }
+ return rows;
+ }
+
+ /**
+ * Convolves a {@link Convolver} across a matrix.
+ *
+ * @param mat
+ * is the row-indexed 2d matrix to convolve.
+ * @param axes
+ * are the x/y major/minor axes.
+ * @param init
+ * is the initial value of the convolution.
+ * @param convolver
+ * is the {@link Convolver}.
+ * @param finalReduction
+ * to apply after {@param convolver}.
+ * @return result of {@param convolver} applied along axes at each cell.
+ * @param <T>
+ * is the type of the matrix to convolve.
+ * @param <R>
+ * is the type of the resulting type of each convolution.
+ */
+ public static <T, R, U> List<List<U>> convolve(final List<List<T>> mat, final Vec2<Integer> axes,
+ final Supplier<R> init, final Convolver<T, R> convolver, final BiFunction<T, R, U> finalReduction) {
+ final List<List<R>> rows = new ArrayList<>();
+ for (int y = 0; y < mat.size(); y++) {
+ final List<R> row = new ArrayList<>(mat.get(y).size());
+ for (int x = 0; x < mat.get(y).size(); x++) {
+ final T center = mat.get(y).get(x);
+ R result = init.get();
+ for (int dy = -axes.getY(); dy <= axes.getY(); dy++) {
+ final int ry = y + dy;
+ if (ry < 0 || ry >= mat.size())
+ continue;
+ for (int dx = -axes.getX(); dx <= axes.getX(); dx++) {
+ final int rx = x + dx;
+ if (rx < 0 || rx >= mat.get(ry).size())
+ continue;
+ result = convolver.convolve(mat.get(ry).get(rx), Vec2i.builder().x(dx).y(dy).build(), result);
+ }
+ }
+ row.add(result);
+ }
+ rows.add(row);
+ }
+
+ final List<List<U>> reductions = new ArrayList<>();
+ for (int y = 0; y < mat.size(); y++) {
+ final List<U> reduction = new ArrayList<>(mat.get(y).size());
+ for (int x = 0; x < mat.get(y).size(); x++) {
+ reduction.add(finalReduction.apply(mat.get(y).get(x), rows.get(y).get(x)));
+ }
+ reductions.add(reduction);
+ }
+ return reductions;
+ }
+
+ @FunctionalInterface
+ public interface Convolver<T, R> {
+ R convolve(final T center, final Vec2<Integer> rel, final R reduction);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/ecs/math/Vec2.java b/core/src/main/java/coffee/liz/ecs/math/Vec2.java
new file mode 100644
index 0000000..ec7e531
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/math/Vec2.java
@@ -0,0 +1,65 @@
+package coffee.liz.ecs.math;
+
+/**
+ * Cartesian vectors.
+ *
+ * @param <T>
+ * the numeric type of vector components
+ */
+public interface Vec2<T> {
+ /**
+ * @return the x coordinate
+ */
+ T getX();
+
+ /**
+ * @return the y coordinate
+ */
+ T getY();
+
+ /**
+ * Adds another vector to this vector.
+ *
+ * @param other
+ * the vector to add
+ * @return a new vector with the result
+ */
+ Vec2<T> plus(final Vec2<T> other);
+
+ /**
+ * Subtracts another vector from this vector.
+ *
+ * @param other
+ * the vector to subtract
+ * @return a new vector with the result
+ */
+ Vec2<T> minus(final Vec2<T> other);
+
+ /**
+ * Scales this vector by the given factors.
+ *
+ * @param scaleX
+ * the x scale factor
+ * @param scaleY
+ * the y scale factor
+ * @return a new scaled vector
+ */
+ Vec2<T> scale(final T scaleX, final T scaleY);
+
+ /**
+ * Length of the vector.
+ *
+ * @return length.
+ */
+ float length();
+
+ /**
+ * @return Vec2<Integer> components of {@link Vec2<T>}
+ */
+ Vec2<Integer> intValue();
+
+ /**
+ * @return Vec2<Float> components of {@link Vec2<T>}
+ */
+ Vec2<Float> floatValue();
+}
diff --git a/core/src/main/java/coffee/liz/ecs/math/Vec2f.java b/core/src/main/java/coffee/liz/ecs/math/Vec2f.java
new file mode 100644
index 0000000..46f3fb8
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/math/Vec2f.java
@@ -0,0 +1,54 @@
+package coffee.liz.ecs.math;
+
+import static java.lang.Math.sqrt;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/** Float impl of {@link Vec2}. */
+@Getter
+@RequiredArgsConstructor
+@Data
+@Builder
+public final class Vec2f implements Vec2<Float> {
+ /** X coordinate. */
+ private final Float x;
+
+ /** Y coordinate. */
+ private final Float y;
+
+ @Override
+ public Vec2<Float> plus(final Vec2<Float> other) {
+ return new Vec2f(x + other.getX(), y + other.getY());
+ }
+
+ @Override
+ public Vec2<Float> minus(final Vec2<Float> other) {
+ return new Vec2f(x - other.getX(), y - other.getY());
+ }
+
+ @Override
+ public Vec2<Float> scale(final Float scaleX, final Float scaleY) {
+ return new Vec2f(x * scaleX, y * scaleY);
+ }
+
+ @Override
+ public float length() {
+ return (float) sqrt(x * x + y * y);
+ }
+
+ @Override
+ public Vec2<Float> floatValue() {
+ return this;
+ }
+
+ @Override
+ public Vec2<Integer> intValue() {
+ return Vec2i.builder().x(this.x.intValue()).y(this.y.intValue()).build();
+ }
+
+ /** Zero float vec */
+ public static Vec2<Float> ZERO = Vec2f.builder().x(0f).y(0f).build();
+}
diff --git a/core/src/main/java/coffee/liz/ecs/math/Vec2i.java b/core/src/main/java/coffee/liz/ecs/math/Vec2i.java
new file mode 100644
index 0000000..dbe246e
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/math/Vec2i.java
@@ -0,0 +1,57 @@
+package coffee.liz.ecs.math;
+
+import static java.lang.Math.sqrt;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/** Integer impl of {@link Vec2}. */
+@Getter
+@RequiredArgsConstructor
+@Builder
+@Data
+public final class Vec2i implements Vec2<Integer> {
+ /** X coordinate. */
+ private final Integer x;
+
+ /** Y coordinate. */
+ private final Integer y;
+
+ @Override
+ public Vec2<Integer> plus(final Vec2<Integer> other) {
+ return new Vec2i(x + other.getX(), y + other.getY());
+ }
+
+ @Override
+ public Vec2<Integer> minus(final Vec2<Integer> other) {
+ return new Vec2i(x - other.getX(), y - other.getY());
+ }
+
+ @Override
+ public Vec2<Integer> scale(final Integer scaleX, final Integer scaleY) {
+ return new Vec2i(x * scaleX, y * scaleY);
+ }
+
+ @Override
+ public Vec2<Float> floatValue() {
+ return Vec2f.builder().x(this.x.floatValue()).y(this.y.floatValue()).build();
+ }
+
+ @Override
+ public Vec2<Integer> intValue() {
+ return this;
+ }
+
+ @Override
+ public float length() {
+ return (float) sqrt(x * x + y * y);
+ }
+
+ public static final Vec2i NORTH = new Vec2i(0, -1);
+ public static final Vec2i SOUTH = new Vec2i(0, 1);
+ public static final Vec2i EAST = new Vec2i(1, 0);
+ public static final Vec2i WEST = new Vec2i(-1, 0);
+ public static final Vec2i ZERO = new Vec2i(0, 0);
+}
diff --git a/core/src/main/java/coffee/liz/ecs/model/Component.java b/core/src/main/java/coffee/liz/ecs/model/Component.java
new file mode 100644
index 0000000..f96ba95
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/model/Component.java
@@ -0,0 +1,8 @@
+package coffee.liz.ecs.model;
+
+/** Component of an {@link Entity}. */
+public interface Component {
+ default Class<? extends Component> getKey() {
+ return getClass();
+ }
+}
diff --git a/core/src/main/java/coffee/liz/ecs/model/Entity.java b/core/src/main/java/coffee/liz/ecs/model/Entity.java
new file mode 100644
index 0000000..e820e57
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/model/Entity.java
@@ -0,0 +1,105 @@
+package coffee.liz.ecs.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+@Getter
+@Builder
+@RequiredArgsConstructor
+@AllArgsConstructor
+@Data
+public class Entity {
+ /** Unique id. */
+ private final int id;
+
+ /** Instances of {@link Component}s. */
+ @Builder.Default
+ private Map<Class<? extends Component>, Component> componentMap = Collections.synchronizedMap(new HashMap<>());
+
+ /**
+ * Check if entity has component type.
+ *
+ * @param componentType
+ * the {@link Component} class
+ * @return true if component exists
+ */
+ public boolean has(final Class<? extends Component> componentType) {
+ return componentMap.containsKey(componentType);
+ }
+
+ /**
+ * Check if entity has all component types.
+ *
+ * @param components
+ * collection of {@link Component} classes
+ * @return true if all components exist
+ */
+ public boolean hasAll(final Collection<Class<? extends Component>> components) {
+ return components.stream().allMatch(this::has);
+ }
+
+ /**
+ * Get component by type.
+ *
+ * @param componentType
+ * the {@link Component} class
+ * @param <C>
+ * component type
+ * @return the component or throw {@link IllegalArgumentException}
+ */
+ @SuppressWarnings("unchecked")
+ public <C extends Component> C get(final Class<C> componentType) {
+ final C component = (C) componentMap.get(componentType);
+ if (component == null) {
+ throw new IllegalArgumentException(
+ "Entity with id " + getId() + " does not have required component " + componentType.getSimpleName());
+ }
+ return component;
+ }
+
+ /**
+ * Add component to entity.
+ *
+ * @param component
+ * the {@link Component} to add
+ * @param <C>
+ * component type
+ * @return this {@link Entity} for chaining
+ */
+ public <C extends Component> Entity add(final C component) {
+ componentMap.put(component.getKey(), component);
+ return this;
+ }
+
+ /**
+ * Remove component from entity.
+ *
+ * @param componentType
+ * the {@link Component} class to remove
+ * @param <C>
+ * component type
+ * @return this {@link Entity} for chaining
+ */
+ public <C extends Component> Entity remove(final Class<C> componentType) {
+ componentMap.remove(componentType);
+ return this;
+ }
+
+ /**
+ * Get all component types.
+ *
+ * @return set of {@link Component} classes
+ */
+ public Set<Class<? extends Component>> componentTypes() {
+ return componentMap.keySet();
+ }
+}
diff --git a/core/src/main/java/coffee/liz/ecs/model/System.java b/core/src/main/java/coffee/liz/ecs/model/System.java
new file mode 100644
index 0000000..220b917
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/model/System.java
@@ -0,0 +1,29 @@
+package coffee.liz.ecs.model;
+
+import java.time.Duration;
+import java.util.Collection;
+
+/**
+ * Updates the {@link World} state.
+ *
+ * @param <T>
+ * is the state of the stuff outside the {@link World}.
+ */
+public interface System<T> {
+ /**
+ * {@link System} clazzes that must run before this system.
+ *
+ * @return {@link Collection} of dependencies.
+ */
+ Collection<Class<? extends System<T>>> getDependencies();
+
+ /**
+ * @param world
+ * Is the {@link World}.
+ * @param state
+ * Is the {@link T} state outside the {@param world}.
+ * @param dt
+ * Is the timestep.
+ */
+ void update(final World<T> world, final T state, final Duration dt);
+}
diff --git a/core/src/main/java/coffee/liz/ecs/model/World.java b/core/src/main/java/coffee/liz/ecs/model/World.java
new file mode 100644
index 0000000..96c7a74
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/model/World.java
@@ -0,0 +1,58 @@
+package coffee.liz.ecs.model;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Set;
+
+/**
+ * The state of the world.
+ *
+ * @param <T>
+ * is the state of the stuff outside the world.
+ */
+public interface World<T> {
+ /**
+ * Create unique {@link Entity} in the {@link World}.
+ *
+ * @return created {@link Entity}.
+ */
+ Entity createEntity();
+
+ /**
+ * Remove an entity from the {@link World}.
+ *
+ * @param entity
+ * to remove.
+ */
+ void removeEntity(final Entity entity);
+
+ /**
+ * Get entities with {@link Component}s.
+ *
+ * @param components
+ * to query for.
+ * @return All entities with all {@param components}.
+ */
+ Set<Entity> query(final Collection<Class<? extends Component>> components);
+
+ /**
+ * Integrate the {@link World}.
+ *
+ * @param state
+ * Is the state outside the world.
+ * @param duration
+ * Is the time step.
+ */
+ void update(final T state, final Duration duration);
+
+ /**
+ * Get world {@link System}.
+ *
+ * @param system
+ * is the Clazz.
+ * @param <S>
+ * is the {@link System} type.
+ * @return {@link System} instance of {@param system}.
+ */
+ <S extends System<T>> S getSystem(final Class<S> system);
+}
diff --git a/core/src/main/java/coffee/liz/lambda/LambdaDriver.java b/core/src/main/java/coffee/liz/lambda/LambdaDriver.java
new file mode 100644
index 0000000..2ba8c70
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/LambdaDriver.java
@@ -0,0 +1,79 @@
+package coffee.liz.lambda;
+
+import coffee.liz.lambda.parser.ArrowParser;
+import coffee.liz.lambda.parser.LambdaParser;
+import coffee.liz.lambda.parser.ParseException;
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.LambdaProgram;
+import coffee.liz.lambda.ast.Macro;
+import coffee.liz.lambda.ast.SourceCode;
+import coffee.liz.lambda.bind.ExternalBinding;
+import coffee.liz.lambda.eval.Environment;
+import coffee.liz.lambda.eval.NormalOrderEvaluator;
+import coffee.liz.lambda.eval.Value;
+
+import java.io.StringReader;
+import java.util.List;
+
+/**
+ * Entry point for parsing and interpreting lambda calculus programs.
+ */
+public class LambdaDriver {
+
+ /**
+ * Parses source code into an AST.
+ *
+ * @param sourceCode
+ * the source code (either Lambda or Arrow syntax)
+ * @return the parsed program
+ */
+ public static LambdaProgram parse(final SourceCode sourceCode) {
+ return switch (sourceCode) {
+ case SourceCode.Lambda(String source) -> parseLambda(source);
+ case SourceCode.Arrow(String source) -> parseArrow(source);
+ };
+ }
+
+ private static LambdaProgram parseLambda(final String source) {
+ try (final StringReader reader = new StringReader(source)) {
+ return new LambdaParser(reader).Program();
+ } catch (final ParseException parseException) {
+ throw new RuntimeException("Failed to parse program", parseException);
+ }
+ }
+
+ private static LambdaProgram parseArrow(final String source) {
+ try (final StringReader reader = new StringReader(source)) {
+ return new ArrowParser(reader).Program();
+ } catch (final ParseException parseException) {
+ throw new RuntimeException("Failed to parse program", parseException);
+ }
+ }
+
+ /**
+ * Parses and evaluates lambda calculus programs.
+ *
+ * @param sourceCode
+ * the source code
+ * @return the evaluated result
+ */
+ public static Value interpret(final SourceCode sourceCode) {
+ return interpret(sourceCode, List.of());
+ }
+
+ /**
+ * Parses and evaluates lambda calculus programs with "FFI"'s.
+ *
+ * @param sourceCode
+ * the source code
+ * @param bindings
+ * external Java functions available during evaluation
+ * @return the evaluated result
+ */
+ public static Value interpret(final SourceCode sourceCode, final List<ExternalBinding> bindings) {
+ final LambdaProgram program = parse(sourceCode);
+ final Expression expression = program.expression();
+ final List<Macro> macros = program.macros();
+ return NormalOrderEvaluator.evaluate(expression, Environment.from(macros, bindings));
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/ast/Expression.java b/core/src/main/java/coffee/liz/lambda/ast/Expression.java
new file mode 100644
index 0000000..6d75a08
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/ast/Expression.java
@@ -0,0 +1,28 @@
+package coffee.liz.lambda.ast;
+
+import lombok.NonNull;
+
+import java.util.Optional;
+
+/**
+ * Represents an expression in the untyped lambda calculus.
+ */
+public sealed interface Expression
+ permits Expression.AbstractionExpression, Expression.IdentifierExpression, Expression.ApplicationExpression {
+
+ Optional<SourceComment> comment();
+
+ SourceSpan span();
+
+ record AbstractionExpression(@NonNull Optional<SourceComment> comment, @NonNull SourceSpan span,
+ @NonNull String parameter, @NonNull Expression body) implements Expression {
+ }
+
+ record ApplicationExpression(@NonNull Optional<SourceComment> comment, @NonNull SourceSpan span,
+ @NonNull Expression applicable, @NonNull Expression argument) implements Expression {
+ }
+
+ record IdentifierExpression(@NonNull Optional<SourceComment> comment, @NonNull SourceSpan span,
+ @NonNull String name) implements Expression {
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/ast/LambdaProgram.java b/core/src/main/java/coffee/liz/lambda/ast/LambdaProgram.java
new file mode 100644
index 0000000..efd4c03
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/ast/LambdaProgram.java
@@ -0,0 +1,19 @@
+package coffee.liz.lambda.ast;
+
+import lombok.NonNull;
+
+import java.util.List;
+
+/**
+ * A complete lambda calculus program consisting of macro definitions and a main
+ * expression.
+ *
+ * @param span
+ * source span covering the entire program
+ * @param macros
+ * named macro definitions that can be referenced in the expression
+ * @param expression
+ * the main expression to evaluate
+ */
+public record LambdaProgram(@NonNull SourceSpan span, @NonNull List<Macro> macros, @NonNull Expression expression) {
+}
diff --git a/core/src/main/java/coffee/liz/lambda/ast/Macro.java b/core/src/main/java/coffee/liz/lambda/ast/Macro.java
new file mode 100644
index 0000000..07ba911
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/ast/Macro.java
@@ -0,0 +1,12 @@
+package coffee.liz.lambda.ast;
+
+import lombok.NonNull;
+
+import java.util.Optional;
+
+/**
+ * A named macro definition that maps an identifier to an expression.
+ */
+public record Macro(@NonNull Optional<SourceComment> comment, @NonNull SourceSpan span, @NonNull String name,
+ @NonNull Expression expression) {
+}
diff --git a/core/src/main/java/coffee/liz/lambda/ast/SourceCode.java b/core/src/main/java/coffee/liz/lambda/ast/SourceCode.java
new file mode 100644
index 0000000..200c45e
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/ast/SourceCode.java
@@ -0,0 +1,27 @@
+package coffee.liz.lambda.ast;
+
+/**
+ * Represents source code in one of the supported lambda calculus syntaxes.
+ */
+public sealed interface SourceCode {
+ static SourceCode ofLambda(final String source) {
+ return new Lambda(source);
+ }
+
+ static SourceCode ofArrow(final String source) {
+ return new Arrow(source);
+ }
+
+ record Lambda(String source) implements SourceCode {
+ }
+
+ record Arrow(String source) implements SourceCode {
+ }
+
+ /**
+ * Supported syntax types for {@link SourceCode}.
+ */
+ enum Syntax {
+ LAMBDA, ARROW
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/ast/SourceComment.java b/core/src/main/java/coffee/liz/lambda/ast/SourceComment.java
new file mode 100644
index 0000000..da6b5ab
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/ast/SourceComment.java
@@ -0,0 +1,21 @@
+package coffee.liz.lambda.ast;
+
+import lombok.NonNull;
+
+/**
+ * A comment with its source location; inline vs leading direction.
+ *
+ * @param text
+ * the comment text content
+ * @param span
+ * the source location of the comment
+ */
+public record SourceComment(@NonNull String text, @NonNull SourceSpan span) {
+
+ /**
+ * Returns true if this comment is on the same line as the given span's end.
+ */
+ public boolean isInlineAfter(final SourceSpan previous) {
+ return previous != null && previous.endLine() == this.span.startLine();
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/ast/SourceSpan.java b/core/src/main/java/coffee/liz/lambda/ast/SourceSpan.java
new file mode 100644
index 0000000..7df9bcd
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/ast/SourceSpan.java
@@ -0,0 +1,24 @@
+package coffee.liz.lambda.ast;
+
+/**
+ * Span of source code with start and end positions.
+ *
+ * @param startLine
+ * 1-based line number where the span starts
+ * @param startColumn
+ * 1-based column number where the span starts
+ * @param endLine
+ * 1-based line number where the span ends
+ * @param endColumn
+ * 1-based column number where the span ends
+ */
+public record SourceSpan(int startLine, int startColumn, int endLine, int endColumn) {
+ public static final SourceSpan UNKNOWN = new SourceSpan(0, 0, 0, 0);
+
+ /**
+ * Returns true if this span ends on the same line that the other span starts.
+ */
+ public boolean isOnSameLine(final SourceSpan other) {
+ return this.endLine == other.startLine;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/bind/ExternalBinding.java b/core/src/main/java/coffee/liz/lambda/bind/ExternalBinding.java
new file mode 100644
index 0000000..4895972
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/bind/ExternalBinding.java
@@ -0,0 +1,23 @@
+package coffee.liz.lambda.bind;
+
+import coffee.liz.lambda.eval.Environment;
+import coffee.liz.lambda.eval.Value;
+
+import java.util.function.BiFunction;
+
+/**
+ * Interface for external Java functions callable from lambda expressions.
+ *
+ * <p>
+ * Implementations receive the current environment and an argument value, and
+ * return a result value.
+ */
+public interface ExternalBinding extends BiFunction<Environment, Value, Value> {
+
+ /**
+ * Returns the name used to reference this binding in environment.
+ *
+ * @return the binding name
+ */
+ String getName();
+}
diff --git a/core/src/main/java/coffee/liz/lambda/bind/Tick.java b/core/src/main/java/coffee/liz/lambda/bind/Tick.java
new file mode 100644
index 0000000..0fa4de5
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/bind/Tick.java
@@ -0,0 +1,22 @@
+package coffee.liz.lambda.bind;
+
+import coffee.liz.lambda.eval.Environment;
+import coffee.liz.lambda.eval.Value;
+import lombok.Getter;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Identity function that has a side effect which internally counts invocations.
+ */
+@Getter
+public class Tick implements ExternalBinding {
+ private final String name = "Tick";
+ private final AtomicInteger counter = new AtomicInteger(0);
+
+ @Override
+ public Value apply(final Environment environment, final Value value) {
+ counter.incrementAndGet();
+ return value;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/bind/ToChurch.java b/core/src/main/java/coffee/liz/lambda/bind/ToChurch.java
new file mode 100644
index 0000000..bfddd86
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/bind/ToChurch.java
@@ -0,0 +1,49 @@
+package coffee.liz.lambda.bind;
+
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.Expression.IdentifierExpression;
+import coffee.liz.lambda.ast.Expression.AbstractionExpression;
+import coffee.liz.lambda.ast.Expression.ApplicationExpression;
+import coffee.liz.lambda.ast.SourceSpan;
+import coffee.liz.lambda.eval.Environment;
+
+import java.util.Optional;
+import coffee.liz.lambda.eval.Value;
+import coffee.liz.lambda.eval.Value.Free;
+import coffee.liz.lambda.eval.Value.Closure;
+import lombok.Getter;
+
+/**
+ * Converts an integer to its Church numeral representation.
+ *
+ * <p>
+ * Church numerals encode n as {@code λf.λx.f(f(...f(x)...))} with n
+ * applications of f.
+ */
+@Getter
+public class ToChurch implements ExternalBinding {
+ private final String name = "ToChurch";
+
+ /**
+ * Converts a free variable containing an integer string to a Church numeral.
+ *
+ * @param env
+ * the current environment
+ * @param val
+ * a Free value whose name is an integer string
+ * @return a Closure representing the Church numeral
+ */
+ @Override
+ public Value apply(final Environment env, final Value val) {
+ final Free free = (Free) val;
+ final int n = Integer.parseInt(free.name());
+
+ Expression body = new IdentifierExpression(Optional.empty(), SourceSpan.UNKNOWN, "x");
+ for (int i = 0; i < n; i++) {
+ body = new ApplicationExpression(Optional.empty(), SourceSpan.UNKNOWN,
+ new IdentifierExpression(Optional.empty(), SourceSpan.UNKNOWN, "f"), body);
+ }
+
+ return new Closure(env, "f", new AbstractionExpression(Optional.empty(), SourceSpan.UNKNOWN, "x", body));
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/eval/Environment.java b/core/src/main/java/coffee/liz/lambda/eval/Environment.java
new file mode 100644
index 0000000..8854719
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/eval/Environment.java
@@ -0,0 +1,109 @@
+package coffee.liz.lambda.eval;
+
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.Macro;
+import coffee.liz.lambda.bind.ExternalBinding;
+import jakarta.annotation.Nullable;
+import lombok.RequiredArgsConstructor;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+/**
+ * Runtime environment for variable bindings, macros, and external bindings.
+ */
+@RequiredArgsConstructor
+public final class Environment {
+ /** Named expansions */
+ private final Map<String, Expression> macros;
+
+ /** "FFI" */
+ private final Map<String, ExternalBinding> externalBindings;
+
+ /** Variable name bound at this scope level. Null for root. */
+ @Nullable
+ private final String boundName;
+
+ /** Lazily-evaluated value for boundName, or null for root. */
+ @Nullable
+ private final Supplier<Value> boundValue;
+
+ /** Enclosing scope, or null for root. Forms a linked list of bindings. */
+ @Nullable
+ private final Environment parent;
+
+ /**
+ * Creates an environment from macro and external binding lists.
+ *
+ * @param macros
+ * program macro definitions
+ * @param externalBindings
+ * external Java bindings for FFI
+ * @return the new environment
+ */
+ public static Environment from(final List<Macro> macros, final List<ExternalBinding> externalBindings) {
+ return new Environment(macros.stream().collect(Collectors.toMap(Macro::name, Macro::expression)),
+ externalBindings.stream().collect(Collectors.toMap(ExternalBinding::getName, Function.identity())),
+ null, null, null);
+ }
+
+ /**
+ * Creates a child scope.
+ *
+ * @param name
+ * the variable name
+ * @param value
+ * the value supplier (thunk)
+ * @return a new environment with the binding added
+ */
+ public Environment extend(final String name, final Supplier<Value> value) {
+ return new Environment(macros, externalBindings, name, value, this);
+ }
+
+ /**
+ * Looks up a name, checking bindings, then macros, then external bindings.
+ *
+ * @param name
+ * the name to look up
+ * @return the lookup result, or empty if not found
+ */
+ public Optional<LookupResult> lookup(final String name) {
+ for (Environment env = this; env != null; env = env.parent) {
+ if (!name.equals(env.boundName)) {
+ continue;
+ }
+ return Optional.of(new LookupResult.Binding(env.boundValue));
+ }
+
+ final Expression macro = macros.get(name);
+ if (macro != null) {
+ return Optional.of(new LookupResult.Macro(macro));
+ }
+
+ final ExternalBinding external = externalBindings.get(name);
+ if (external != null) {
+ return Optional.of(new LookupResult.External(external));
+ }
+
+ return Optional.empty();
+ }
+
+ /**
+ * Result of looking up a name in the environment.
+ */
+ public sealed interface LookupResult {
+ /** A local variable binding. */
+ record Binding(Supplier<Value> value) implements LookupResult {
+ }
+ /** A macro definition. */
+ record Macro(Expression expression) implements LookupResult {
+ }
+ /** An external Java binding. */
+ record External(ExternalBinding binding) implements LookupResult {
+ }
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/eval/EvaluationDepthExceededException.java b/core/src/main/java/coffee/liz/lambda/eval/EvaluationDepthExceededException.java
new file mode 100644
index 0000000..7926004
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/eval/EvaluationDepthExceededException.java
@@ -0,0 +1,16 @@
+package coffee.liz.lambda.eval;
+
+import lombok.Getter;
+
+/**
+ * Thrown before we get a {@link StackOverflowError}, hopefully.
+ */
+@Getter
+public final class EvaluationDepthExceededException extends RuntimeException {
+ private final int maxDepth;
+
+ public EvaluationDepthExceededException(final int maxDepth) {
+ super("Evaluation exceeded maximum depth of " + maxDepth);
+ this.maxDepth = maxDepth;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/eval/NormalOrderEvaluator.java b/core/src/main/java/coffee/liz/lambda/eval/NormalOrderEvaluator.java
new file mode 100644
index 0000000..28eb69b
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/eval/NormalOrderEvaluator.java
@@ -0,0 +1,101 @@
+package coffee.liz.lambda.eval;
+
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.Expression.AbstractionExpression;
+import coffee.liz.lambda.ast.Expression.ApplicationExpression;
+import coffee.liz.lambda.ast.Expression.IdentifierExpression;
+import coffee.liz.lambda.bind.ExternalBinding;
+import coffee.liz.lambda.eval.Environment.LookupResult;
+import coffee.liz.lambda.eval.Value.Application;
+import coffee.liz.lambda.eval.Value.Closure;
+import coffee.liz.lambda.eval.Value.Free;
+
+/**
+ * Evaluates lambda expressions using normal order (lazy) reduction.
+ *
+ * <p>
+ * Arguments are wrapped in {@link Thunk} and only evaluated when needed.
+ */
+public final class NormalOrderEvaluator {
+
+ public static final int DEFAULT_MAX_DEPTH = 140;
+
+ /**
+ * Evaluates an expression in the given environment with default depth limit.
+ *
+ * @param term
+ * the expression to evaluate
+ * @param env
+ * the environment containing bindings
+ * @return the resulting value
+ * @throws EvaluationDepthExceededException
+ * if evaluation exceeds {@link #DEFAULT_MAX_DEPTH}
+ */
+ public static Value evaluate(final Expression term, final Environment env) {
+ return evaluate(term, env, DEFAULT_MAX_DEPTH);
+ }
+
+ /**
+ * Evaluates an expression in the given environment with specified depth limit.
+ *
+ * @param term
+ * the expression to evaluate
+ * @param env
+ * the environment containing bindings
+ * @param maxDepth
+ * maximum evaluation depth
+ * @return the resulting value
+ * @throws EvaluationDepthExceededException
+ * if evaluation exceeds maxDepth
+ */
+ public static Value evaluate(final Expression term, final Environment env, final int maxDepth) {
+ return evaluate(term, env, maxDepth, 0);
+ }
+
+ private static Value evaluate(final Expression term, final Environment env, final int maxDepth, final int depth) {
+ if (depth > maxDepth) {
+ throw new EvaluationDepthExceededException(maxDepth);
+ }
+
+ return switch (term) {
+ case IdentifierExpression(var _, var _, final String name) ->
+ env.lookup(name).map(result -> switch (result) {
+ case LookupResult.Binding(final var value) -> value.get();
+ case LookupResult.Macro(final var expr) -> evaluate(expr, env, maxDepth, depth + 1);
+ case LookupResult.External(final var binding) -> new Free(binding.getName());
+ }).orElseGet(() -> new Free(name));
+
+ case AbstractionExpression(var _, var _, final String parameter, final Expression body) ->
+ new Closure(env, parameter, body);
+
+ case ApplicationExpression(var _, var _, final Expression func, final Expression arg) ->
+ apply(evaluate(func, env, maxDepth, depth + 1), arg, env, maxDepth, depth + 1);
+ };
+ }
+
+ private static Value apply(final Value funcVal, final Expression arg, final Environment argEnv, final int maxDepth,
+ final int depth) {
+ if (depth > maxDepth) {
+ throw new EvaluationDepthExceededException(maxDepth);
+ }
+
+ return switch (funcVal) {
+ case Closure(final Environment closureEnv, final String parameter, final Expression body) -> {
+ final Thunk<Value> thunk = new Thunk<>(() -> evaluate(arg, argEnv, maxDepth, depth + 1));
+ final Environment newEnv = closureEnv.extend(parameter, thunk);
+ yield evaluate(body, newEnv, maxDepth, depth + 1);
+ }
+
+ case Free(final String name) ->
+ argEnv.lookup(name).filter(r -> r instanceof LookupResult.External).map(r -> {
+ final ExternalBinding binding = ((LookupResult.External) r).binding();
+ return binding.apply(argEnv, evaluate(arg, argEnv, maxDepth, depth + 1));
+ }).orElseGet(() -> new Application(funcVal, evaluate(arg, argEnv, maxDepth, depth + 1)));
+
+ case Application app -> {
+ final Value argVal = evaluate(arg, argEnv, maxDepth, depth + 1);
+ yield new Application(app, argVal);
+ }
+ };
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/eval/Thunk.java b/core/src/main/java/coffee/liz/lambda/eval/Thunk.java
new file mode 100644
index 0000000..07c89bf
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/eval/Thunk.java
@@ -0,0 +1,27 @@
+package coffee.liz.lambda.eval;
+
+import lombok.RequiredArgsConstructor;
+
+import java.util.function.Supplier;
+
+/**
+ * A memoizing thunk for lazy evaluation.
+ *
+ * @param <T>
+ * Thunk type
+ */
+@RequiredArgsConstructor
+public final class Thunk<T> implements Supplier<T> {
+ private final Supplier<T> thinker; // https://www.youtube.com/shorts/Dzksib8YxSY
+ private T cached = null;
+ private boolean evaluated = false;
+
+ @Override
+ public T get() {
+ if (!evaluated) {
+ cached = thinker.get();
+ evaluated = true;
+ }
+ return cached;
+ }
+} \ No newline at end of file
diff --git a/core/src/main/java/coffee/liz/lambda/eval/Value.java b/core/src/main/java/coffee/liz/lambda/eval/Value.java
new file mode 100644
index 0000000..58b9ba4
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/eval/Value.java
@@ -0,0 +1,42 @@
+package coffee.liz.lambda.eval;
+
+import coffee.liz.lambda.ast.Expression;
+
+/**
+ * Represents a runtime value produced by evaluation.
+ */
+public sealed interface Value permits Value.Closure, Value.Application, Value.Free {
+
+ /**
+ * A closure capturing an environment, parameter, and body.
+ *
+ * @param env
+ * the captured environment
+ * @param parameter
+ * the bound parameter name
+ * @param body
+ * the lambda body expression
+ */
+ record Closure(Environment env, String parameter, Expression body) implements Value {
+ }
+
+ /**
+ * A symbolic application of a function to an argument.
+ *
+ * @param function
+ * the function
+ * @param argument
+ * the argument
+ */
+ record Application(Value function, Value argument) implements Value {
+ }
+
+ /**
+ * A free variable.
+ *
+ * @param name
+ * the variable name
+ */
+ record Free(String name) implements Value {
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/format/Formatter.java b/core/src/main/java/coffee/liz/lambda/format/Formatter.java
new file mode 100644
index 0000000..1ac3e95
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/format/Formatter.java
@@ -0,0 +1,238 @@
+package coffee.liz.lambda.format;
+
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.Expression.AbstractionExpression;
+import coffee.liz.lambda.ast.Expression.ApplicationExpression;
+import coffee.liz.lambda.ast.Expression.IdentifierExpression;
+import coffee.liz.lambda.ast.LambdaProgram;
+import coffee.liz.lambda.ast.Macro;
+import coffee.liz.lambda.ast.SourceComment;
+import coffee.liz.lambda.ast.SourceSpan;
+import lombok.RequiredArgsConstructor;
+import coffee.liz.lambda.ast.SourceCode.Syntax;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Formats lambda calculus ASTs.
+ */
+@RequiredArgsConstructor
+public final class Formatter {
+
+ private final Syntax syntax;
+
+ public String format(final LambdaProgram program) {
+ final StringBuilder sb = new StringBuilder();
+ SourceSpan previousEnd = null;
+
+ for (int i = 0; i < program.macros().size(); i++) {
+ final Macro macro = program.macros().get(i);
+
+ if (macro.comment().isPresent()) {
+ final SourceComment comment = macro.comment().get();
+ final String leadingPart = getLeadingCommentPart(comment, previousEnd);
+ if (!leadingPart.isEmpty()) {
+ sb.append(leadingPart);
+ if (!leadingPart.endsWith("\n")) {
+ sb.append("\n");
+ }
+ }
+ }
+
+ sb.append("let ").append(macro.name()).append(" = ");
+ sb.append(formatExpression(macro.expression(), false));
+ sb.append(";");
+
+ final Optional<String> inlineComment = getInlineCommentAfter(macro.span(), program, i);
+ inlineComment.ifPresent(c -> sb.append(" ").append(c));
+ sb.append("\n");
+
+ previousEnd = macro.span();
+ }
+
+ boolean isTrailingComment = false;
+ boolean inlinePartAlreadyOutput = false;
+
+ if (program.expression().comment().isPresent()) {
+ final SourceComment comment = program.expression().comment().get();
+
+ isTrailingComment = comment.span().startLine() >= program.expression().span().startLine()
+ && comment.span().startColumn() > program.expression().span().startColumn();
+
+ if (!isTrailingComment) {
+ inlinePartAlreadyOutput = previousEnd != null && comment.isInlineAfter(previousEnd);
+
+ final String leadingPart = getLeadingCommentPart(comment, previousEnd);
+ if (!leadingPart.isEmpty()) {
+ sb.append(leadingPart);
+ if (!leadingPart.endsWith("\n")) {
+ sb.append("\n");
+ }
+ } else if (!program.macros().isEmpty()) {
+ sb.append("\n");
+ }
+ } else if (!program.macros().isEmpty()) {
+ sb.append("\n");
+ }
+ } else if (!program.macros().isEmpty()) {
+ sb.append("\n");
+ }
+
+ sb.append(formatExpression(program.expression(), false));
+
+ if (program.expression().comment().isPresent() && !inlinePartAlreadyOutput && isTrailingComment) {
+ sb.append(" ").append(program.expression().comment().get().text());
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Gets the inline portion of a comment (first line if on same line as previous
+ * element).
+ */
+ private String getInlineCommentPart(final SourceComment comment, final SourceSpan previousEnd) {
+ if (previousEnd == null || !comment.isInlineAfter(previousEnd)) {
+ return "";
+ }
+ final String text = comment.text();
+ final int newlineIndex = text.indexOf('\n');
+ return newlineIndex == -1 ? text : text.substring(0, newlineIndex);
+ }
+
+ /**
+ * Gets the leading portion of a comment (all lines except inline first line).
+ */
+ private String getLeadingCommentPart(final SourceComment comment, final SourceSpan previousEnd) {
+ if (previousEnd == null || !comment.isInlineAfter(previousEnd)) {
+ return comment.text();
+ }
+ final String text = comment.text();
+ final int newlineIndex = text.indexOf('\n');
+ return newlineIndex == -1 ? "" : text.substring(newlineIndex + 1);
+ }
+
+ /**
+ * Gets the inline comment that appears after the given span, if any.
+ */
+ private Optional<String> getInlineCommentAfter(final SourceSpan span, final LambdaProgram program,
+ final int macroIndex) {
+ if (macroIndex + 1 < program.macros().size()) {
+ final Macro nextMacro = program.macros().get(macroIndex + 1);
+ if (nextMacro.comment().isPresent() && nextMacro.comment().get().isInlineAfter(span)) {
+ final String inlinePart = getInlineCommentPart(nextMacro.comment().get(), span);
+ if (!inlinePart.isEmpty()) {
+ return Optional.of(inlinePart);
+ }
+ }
+ }
+ if (macroIndex == program.macros().size() - 1 && program.expression().comment().isPresent()
+ && program.expression().comment().get().isInlineAfter(span)) {
+ final String inlinePart = getInlineCommentPart(program.expression().comment().get(), span);
+ if (!inlinePart.isEmpty()) {
+ return Optional.of(inlinePart);
+ }
+ }
+ return Optional.empty();
+ }
+
+ private String formatExpression(final Expression expr, final boolean needsParens) {
+ return switch (expr) {
+ case AbstractionExpression abs -> formatAbstraction(abs, needsParens);
+ case ApplicationExpression app -> formatApplication(app, needsParens);
+ case IdentifierExpression id -> id.name();
+ };
+ }
+
+ private String formatAbstraction(final AbstractionExpression abs, final boolean needsParens) {
+ final StringBuilder sb = new StringBuilder();
+
+ if (needsParens) {
+ sb.append("(");
+ }
+ switch (syntax) {
+ case LAMBDA -> {
+ sb.append("λ").append(abs.parameter()).append(".");
+ sb.append(formatExpression(abs.body(), false));
+ }
+ case ARROW -> {
+ sb.append(abs.parameter()).append(" -> ");
+ sb.append(formatExpression(abs.body(), false));
+ }
+ }
+ if (needsParens) {
+ sb.append(")");
+ }
+
+ return sb.toString();
+ }
+
+ private String formatApplication(final ApplicationExpression app, final boolean needsParens) {
+ return switch (syntax) {
+ case LAMBDA -> formatLambdaApplication(app, needsParens);
+ case ARROW -> formatArrowApplication(app);
+ };
+ }
+
+ private String formatLambdaApplication(final ApplicationExpression app, final boolean needsParens) {
+ final StringBuilder sb = new StringBuilder();
+
+ if (needsParens) {
+ sb.append("(");
+ }
+
+ final boolean funcNeedsParens = app.applicable() instanceof AbstractionExpression;
+ sb.append(formatExpression(app.applicable(), funcNeedsParens));
+
+ sb.append(" ");
+
+ final boolean argNeedsParens = app.argument() instanceof ApplicationExpression
+ || app.argument() instanceof AbstractionExpression;
+ sb.append(formatExpression(app.argument(), argNeedsParens));
+
+ if (needsParens) {
+ sb.append(")");
+ }
+
+ return sb.toString();
+ }
+
+ private String formatArrowApplication(final ApplicationExpression app) {
+ final StringBuilder sb = new StringBuilder();
+
+ Expression func = app.applicable();
+ final List<Expression> args = new ArrayList<>();
+ args.add(app.argument());
+
+ while (func instanceof ApplicationExpression appFunc) {
+ args.addFirst(appFunc.argument());
+ func = appFunc.applicable();
+ }
+
+ final boolean funcNeedsParens = func instanceof AbstractionExpression;
+ if (funcNeedsParens) {
+ sb.append("(");
+ }
+ sb.append(formatExpression(func, false));
+ if (funcNeedsParens) {
+ sb.append(")");
+ }
+
+ for (final Expression arg : args) {
+ sb.append("(");
+ sb.append(formatExpression(arg, false));
+ sb.append(")");
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Formats a program in the specified syntax.
+ */
+ public static String emit(final LambdaProgram program, final Syntax syntax) {
+ return new Formatter(syntax).format(program);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/lambda/parser/ArrowParser.jjt b/core/src/main/java/coffee/liz/lambda/parser/ArrowParser.jjt
new file mode 100644
index 0000000..9ad9099
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/parser/ArrowParser.jjt
@@ -0,0 +1,276 @@
+options {
+ STATIC = false;
+ LOOKAHEAD = 2;
+ UNICODE_INPUT = true;
+ NODE_PREFIX = "AST";
+}
+
+PARSER_BEGIN(ArrowParser)
+package coffee.liz.lambda.parser;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import coffee.liz.lambda.ast.Expression.AbstractionExpression;
+import coffee.liz.lambda.ast.Expression.ApplicationExpression;
+import coffee.liz.lambda.ast.Expression.IdentifierExpression;
+import coffee.liz.lambda.ast.LambdaProgram;
+import coffee.liz.lambda.ast.Macro;
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.SourceSpan;
+import coffee.liz.lambda.ast.SourceComment;
+
+public class ArrowParser {
+ public static void main(final String[] args) throws Exception {
+ final ArrowParser parser = new ArrowParser(System.in);
+ final LambdaProgram program = parser.Program();
+ System.out.println(program);
+ }
+
+ private static SourceSpan spanFrom(final Token start, final Token end) {
+ return new SourceSpan(start.beginLine, start.beginColumn, end.endLine, end.endColumn);
+ }
+
+ private static SourceSpan spanFrom(final Token start, final Expression end) {
+ return new SourceSpan(start.beginLine, start.beginColumn, end.span().endLine(), end.span().endColumn());
+ }
+
+ private static SourceSpan spanFromInts(final int startLine, final int startColumn, final Token end) {
+ return new SourceSpan(startLine, startColumn, end.endLine, end.endColumn);
+ }
+
+ private static SourceSpan spanFromInts(final int startLine, final int startColumn, final Expression end) {
+ return new SourceSpan(startLine, startColumn, end.span().endLine(), end.span().endColumn());
+ }
+
+ private static Expression withComment(final Expression expr, final Optional<SourceComment> comment) {
+ if (comment.isEmpty()) {
+ return expr;
+ }
+ if (expr instanceof AbstractionExpression) {
+ final AbstractionExpression a = (AbstractionExpression) expr;
+ return new AbstractionExpression(comment, a.span(), a.parameter(), a.body());
+ }
+ if (expr instanceof ApplicationExpression) {
+ final ApplicationExpression a = (ApplicationExpression) expr;
+ return new ApplicationExpression(comment, a.span(), a.applicable(), a.argument());
+ }
+ if (expr instanceof IdentifierExpression) {
+ final IdentifierExpression a = (IdentifierExpression) expr;
+ return new IdentifierExpression(comment, a.span(), a.name());
+ }
+ return expr;
+ }
+
+ private static Expression withSpan(final Expression expr, final SourceSpan span) {
+ if (expr instanceof AbstractionExpression) {
+ final AbstractionExpression a = (AbstractionExpression) expr;
+ return new AbstractionExpression(a.comment(), span, a.parameter(), a.body());
+ }
+ if (expr instanceof ApplicationExpression) {
+ final ApplicationExpression a = (ApplicationExpression) expr;
+ return new ApplicationExpression(a.comment(), span, a.applicable(), a.argument());
+ }
+ if (expr instanceof IdentifierExpression) {
+ final IdentifierExpression a = (IdentifierExpression) expr;
+ return new IdentifierExpression(a.comment(), span, a.name());
+ }
+ return expr;
+ }
+
+ private static Expression withCommentAndSpan(final Expression expr, final Optional<SourceComment> comment, final SourceSpan span) {
+ if (expr instanceof AbstractionExpression) {
+ final AbstractionExpression a = (AbstractionExpression) expr;
+ return new AbstractionExpression(comment, span, a.parameter(), a.body());
+ }
+ if (expr instanceof ApplicationExpression) {
+ final ApplicationExpression a = (ApplicationExpression) expr;
+ return new ApplicationExpression(comment, span, a.applicable(), a.argument());
+ }
+ if (expr instanceof IdentifierExpression) {
+ final IdentifierExpression a = (IdentifierExpression) expr;
+ return new IdentifierExpression(comment, span, a.name());
+ }
+ return expr;
+ }
+
+ private static Optional<SourceComment> extractComment(final Token t) {
+ if (t == null || t.specialToken == null) {
+ return Optional.empty();
+ }
+ Token st = t.specialToken;
+ while (st.specialToken != null) {
+ st = st.specialToken;
+ }
+ final Token firstComment = st;
+ final StringBuilder sb = new StringBuilder();
+ Token lastComment = st;
+ int lastEndLine = 0;
+ while (st != null) {
+ if (sb.length() > 0) {
+ final int blankLines = st.beginLine - lastEndLine - 1;
+ for (int i = 0; i < blankLines; i++) {
+ sb.append("\n");
+ }
+ sb.append("\n");
+ }
+ String image = st.image;
+ while (image.endsWith("\n") || image.endsWith("\r")) {
+ image = image.substring(0, image.length() - 1);
+ }
+ sb.append(image);
+ lastEndLine = st.endLine;
+ lastComment = st;
+ st = st.next;
+ }
+ final SourceSpan span = new SourceSpan(
+ firstComment.beginLine, firstComment.beginColumn,
+ lastComment.endLine, lastComment.endColumn
+ );
+ return Optional.of(new SourceComment(sb.toString(), span));
+ }
+}
+PARSER_END(ArrowParser)
+
+SKIP : {
+ " " | "\t" | "\r" | "\n"
+}
+
+SPECIAL_TOKEN : {
+ < COMMENT: "--" (~["\n","\r"])* ("\n"|"\r"|"\r\n")? >
+}
+
+TOKEN : {
+ < LET: "let" >
+ | < ARROW: "->" >
+ | < EQ: "=" >
+ | < SEMI: ";" >
+ | < LPAREN: "(" >
+ | < RPAREN: ")" >
+ | < IDENT: (["a"-"z","A"-"Z","0"-"9","_"])+ >
+}
+
+LambdaProgram Program() :
+{
+ final List<Macro> macros = new ArrayList<Macro>();
+ Macro m;
+ Expression body;
+ Token eofToken;
+ Optional<SourceComment> eofComment;
+}
+{
+ (
+ m = Macro()
+ { macros.add(m); }
+ )*
+ body = Expression()
+ eofToken = <EOF>
+ { eofComment = extractComment(eofToken); }
+ {
+ final SourceSpan span;
+ if (!macros.isEmpty()) {
+ span = spanFromInts(macros.get(0).span().startLine(), macros.get(0).span().startColumn(), eofToken);
+ } else {
+ span = new SourceSpan(body.span().startLine(), body.span().startColumn(), eofToken.endLine, eofToken.endColumn);
+ }
+ if (eofComment.isPresent() && body.comment().isEmpty()) {
+ body = withComment(body, eofComment);
+ }
+ return new LambdaProgram(span, macros, body);
+ }
+}
+
+Macro Macro() :
+{
+ Token letToken;
+ Token name;
+ Token semiToken;
+ Expression value;
+ Optional<SourceComment> comment;
+}
+{
+ letToken = <LET>
+ { comment = extractComment(letToken); }
+ name = <IDENT>
+ <EQ>
+ value = Expression()
+ semiToken = <SEMI>
+ {
+ return new Macro(comment, spanFrom(letToken, semiToken), name.image, value);
+ }
+}
+
+Expression Expression() :
+{
+ Token paramToken;
+ Expression e;
+ Expression body;
+ Optional<SourceComment> comment;
+}
+{
+ (
+ LOOKAHEAD(<IDENT> <ARROW>)
+ paramToken = <IDENT>
+ { comment = extractComment(paramToken); }
+ <ARROW>
+ body = Expression()
+ {
+ e = new AbstractionExpression(comment, spanFrom(paramToken, body), paramToken.image, body);
+ }
+ |
+ e = Application()
+ )
+ {
+ return e;
+ }
+}
+
+Expression Application() :
+{
+ Expression e;
+ Expression arg;
+ Token rparen;
+}
+{
+ e = Atom()
+ (
+ <LPAREN>
+ arg = Expression()
+ rparen = <RPAREN>
+ {
+ e = new ApplicationExpression(Optional.empty(), spanFromInts(e.span().startLine(), e.span().startColumn(), rparen), e, arg);
+ }
+ )*
+ {
+ return e;
+ }
+}
+
+Expression Atom() :
+{
+ Token id;
+ Token lparen;
+ Token rparen;
+ Expression e;
+ Optional<SourceComment> comment;
+}
+{
+ id = <IDENT>
+ { comment = extractComment(id); }
+ {
+ return new IdentifierExpression(comment, spanFrom(id, id), id.image);
+ }
+|
+ lparen = <LPAREN>
+ { comment = extractComment(lparen); }
+ e = Expression()
+ rparen = <RPAREN>
+ {
+ final SourceSpan parenSpan = spanFrom(lparen, rparen);
+ if (comment.isPresent() && e.comment().isEmpty()) {
+ return withCommentAndSpan(e, comment, parenSpan);
+ }
+ return withSpan(e, parenSpan);
+ }
+}
+
diff --git a/core/src/main/java/coffee/liz/lambda/parser/LambdaParser.jjt b/core/src/main/java/coffee/liz/lambda/parser/LambdaParser.jjt
new file mode 100644
index 0000000..2482960
--- /dev/null
+++ b/core/src/main/java/coffee/liz/lambda/parser/LambdaParser.jjt
@@ -0,0 +1,287 @@
+options {
+ STATIC = false;
+ LOOKAHEAD = 2;
+ UNICODE_INPUT = true;
+ NODE_PREFIX = "AST";
+}
+
+PARSER_BEGIN(LambdaParser)
+package coffee.liz.lambda.parser;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import coffee.liz.lambda.ast.Expression.AbstractionExpression;
+import coffee.liz.lambda.ast.Expression.ApplicationExpression;
+import coffee.liz.lambda.ast.Expression.IdentifierExpression;
+import coffee.liz.lambda.ast.LambdaProgram;
+import coffee.liz.lambda.ast.Macro;
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.SourceSpan;
+import coffee.liz.lambda.ast.SourceComment;
+
+public class LambdaParser {
+ public static void main(final String[] args) throws Exception {
+ final LambdaParser parser = new LambdaParser(System.in);
+ final LambdaProgram program = parser.Program();
+ System.out.println(program);
+ }
+
+ private static SourceSpan spanFrom(final Token start, final Token end) {
+ return new SourceSpan(start.beginLine, start.beginColumn, end.endLine, end.endColumn);
+ }
+
+ private static SourceSpan spanFrom(final Token start, final Expression end) {
+ return new SourceSpan(start.beginLine, start.beginColumn, end.span().endLine(), end.span().endColumn());
+ }
+
+ private static SourceSpan spanFromInts(final int startLine, final int startColumn, final Token end) {
+ return new SourceSpan(startLine, startColumn, end.endLine, end.endColumn);
+ }
+
+ private static SourceSpan spanFromInts(final int startLine, final int startColumn, final Expression end) {
+ return new SourceSpan(startLine, startColumn, end.span().endLine(), end.span().endColumn());
+ }
+
+ private static Expression withComment(final Expression expr, final Optional<SourceComment> comment) {
+ if (comment.isEmpty()) {
+ return expr;
+ }
+ if (expr instanceof AbstractionExpression) {
+ final AbstractionExpression a = (AbstractionExpression) expr;
+ return new AbstractionExpression(comment, a.span(), a.parameter(), a.body());
+ }
+ if (expr instanceof ApplicationExpression) {
+ final ApplicationExpression a = (ApplicationExpression) expr;
+ return new ApplicationExpression(comment, a.span(), a.applicable(), a.argument());
+ }
+ if (expr instanceof IdentifierExpression) {
+ final IdentifierExpression a = (IdentifierExpression) expr;
+ return new IdentifierExpression(comment, a.span(), a.name());
+ }
+ return expr;
+ }
+
+ private static Expression withSpan(final Expression expr, final SourceSpan span) {
+ if (expr instanceof AbstractionExpression) {
+ final AbstractionExpression a = (AbstractionExpression) expr;
+ return new AbstractionExpression(a.comment(), span, a.parameter(), a.body());
+ }
+ if (expr instanceof ApplicationExpression) {
+ final ApplicationExpression a = (ApplicationExpression) expr;
+ return new ApplicationExpression(a.comment(), span, a.applicable(), a.argument());
+ }
+ if (expr instanceof IdentifierExpression) {
+ final IdentifierExpression a = (IdentifierExpression) expr;
+ return new IdentifierExpression(a.comment(), span, a.name());
+ }
+ return expr;
+ }
+
+ private static Expression withCommentAndSpan(final Expression expr, final Optional<SourceComment> comment, final SourceSpan span) {
+ if (expr instanceof AbstractionExpression) {
+ final AbstractionExpression a = (AbstractionExpression) expr;
+ return new AbstractionExpression(comment, span, a.parameter(), a.body());
+ }
+ if (expr instanceof ApplicationExpression) {
+ final ApplicationExpression a = (ApplicationExpression) expr;
+ return new ApplicationExpression(comment, span, a.applicable(), a.argument());
+ }
+ if (expr instanceof IdentifierExpression) {
+ final IdentifierExpression a = (IdentifierExpression) expr;
+ return new IdentifierExpression(comment, span, a.name());
+ }
+ return expr;
+ }
+
+ private static Optional<SourceComment> extractComment(final Token t) {
+ if (t == null || t.specialToken == null) {
+ return Optional.empty();
+ }
+ Token st = t.specialToken;
+ while (st.specialToken != null) {
+ st = st.specialToken;
+ }
+ final Token firstComment = st;
+ final StringBuilder sb = new StringBuilder();
+ Token lastComment = st;
+ int lastEndLine = 0;
+ while (st != null) {
+ if (sb.length() > 0) {
+ final int blankLines = st.beginLine - lastEndLine - 1;
+ for (int i = 0; i < blankLines; i++) {
+ sb.append("\n");
+ }
+ sb.append("\n");
+ }
+ String image = st.image;
+ while (image.endsWith("\n") || image.endsWith("\r")) {
+ image = image.substring(0, image.length() - 1);
+ }
+ sb.append(image);
+ lastEndLine = st.endLine;
+ lastComment = st;
+ st = st.next;
+ }
+ final SourceSpan span = new SourceSpan(
+ firstComment.beginLine, firstComment.beginColumn,
+ lastComment.endLine, lastComment.endColumn
+ );
+ return Optional.of(new SourceComment(sb.toString(), span));
+ }
+}
+PARSER_END(LambdaParser)
+
+SKIP : {
+ " " | "\t" | "\r" | "\n"
+}
+
+SPECIAL_TOKEN : {
+ < COMMENT: "--" (~["\n","\r"])* ("\n"|"\r"|"\r\n")? >
+}
+
+TOKEN : {
+ < LET: "let" >
+ | < LAMBDA: "λ" | "\\" >
+ | < DOT: "." >
+ | < EQ: "=" >
+ | < SEMI: ";" >
+ | < LPAREN: "(" >
+ | < RPAREN: ")" >
+ | < IDENT: (["a"-"z","A"-"Z","0"-"9","_"])+ >
+}
+
+LambdaProgram Program() :
+{
+ final List<Macro> macros = new ArrayList<Macro>();
+ Macro m;
+ Expression body;
+ Token eofToken;
+ Optional<SourceComment> eofComment;
+}
+{
+ (
+ m = Macro()
+ { macros.add(m); }
+ )*
+ body = Expression()
+ eofToken = <EOF>
+ { eofComment = extractComment(eofToken); }
+ {
+ final SourceSpan span;
+ if (!macros.isEmpty()) {
+ span = spanFromInts(macros.get(0).span().startLine(), macros.get(0).span().startColumn(), eofToken);
+ } else {
+ span = new SourceSpan(body.span().startLine(), body.span().startColumn(), eofToken.endLine, eofToken.endColumn);
+ }
+ if (eofComment.isPresent() && body.comment().isEmpty()) {
+ body = withComment(body, eofComment);
+ }
+ return new LambdaProgram(span, macros, body);
+ }
+}
+
+Macro Macro() :
+{
+ Token letToken;
+ Token name;
+ Token semiToken;
+ Expression value;
+ Optional<SourceComment> comment;
+}
+{
+ letToken = <LET>
+ { comment = extractComment(letToken); }
+ name = <IDENT>
+ <EQ>
+ value = Expression()
+ semiToken = <SEMI>
+ {
+ return new Macro(comment, spanFrom(letToken, semiToken), name.image, value);
+ }
+}
+
+Expression Expression() :
+{
+ Expression e;
+}
+{
+ (
+ e = Lambda()
+ |
+ e = Application()
+ )
+ {
+ return e;
+ }
+}
+
+Expression Lambda() :
+{
+ Token lambdaToken;
+ Token param;
+ Expression body;
+ Optional<SourceComment> comment;
+}
+{
+ lambdaToken = <LAMBDA>
+ { comment = extractComment(lambdaToken); }
+ param = <IDENT>
+ <DOT>
+ body = Expression()
+ {
+ return new AbstractionExpression(comment, spanFrom(lambdaToken, body), param.image, body);
+ }
+}
+
+Expression Application() :
+{
+ Expression e;
+ Expression arg;
+}
+{
+ e = Atom()
+ (
+ arg = Atom()
+ {
+ e = new ApplicationExpression(Optional.empty(), spanFromInts(e.span().startLine(), e.span().startColumn(), arg), e, arg);
+ }
+ )*
+ {
+ return e;
+ }
+}
+
+Expression Atom() :
+{
+ Token id;
+ Token lparen;
+ Token rparen;
+ Expression e;
+ Optional<SourceComment> comment;
+}
+{
+ id = <IDENT>
+ { comment = extractComment(id); }
+ {
+ return new IdentifierExpression(comment, spanFrom(id, id), id.image);
+ }
+|
+ lparen = <LPAREN>
+ { comment = extractComment(lparen); }
+ e = Expression()
+ rparen = <RPAREN>
+ {
+ final SourceSpan parenSpan = spanFrom(lparen, rparen);
+ if (comment.isPresent() && e.comment().isEmpty()) {
+ return withCommentAndSpan(e, comment, parenSpan);
+ }
+ return withSpan(e, parenSpan);
+ }
+|
+ e = Lambda()
+ {
+ return e;
+ }
+}
+
diff --git a/core/src/main/resources/log4j2.xml b/core/src/main/resources/log4j2.xml
new file mode 100644
index 0000000..c52aaea
--- /dev/null
+++ b/core/src/main/resources/log4j2.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Configuration status="WARN">
+ <Appenders>
+ <Console name="Console" target="SYSTEM_OUT">
+ <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
+ </Console>
+ </Appenders>
+ <Loggers>
+ <Root level="info">
+ <AppenderRef ref="Console"/>
+ </Root>
+ </Loggers>
+</Configuration>
diff --git a/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystemTest.java b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystemTest.java
new file mode 100644
index 0000000..3743f3e
--- /dev/null
+++ b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridCollisionPropagatationSystemTest.java
@@ -0,0 +1,188 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import coffee.liz.abstractionengine.grid.component.GridCollidable;
+import coffee.liz.abstractionengine.grid.component.GridCollidable.CollisionBehavior;
+import coffee.liz.abstractionengine.grid.component.GridCollidable.CollisionBehavior.CollisionBehaviorType;
+import coffee.liz.abstractionengine.grid.component.GridInputState;
+import coffee.liz.abstractionengine.grid.component.GridMomentum;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.math.Vec2i;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.World;
+
+import lombok.Getter;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+final class GridCollisionPropagatationSystemTest {
+ private static final Duration FRAME = Duration.ZERO;
+ private static final GridCollidable WALL_COLLIDABLE = (me, them) -> CollisionBehavior.builder()
+ .collisionBehaviorType(CollisionBehaviorType.WALL).priority(10).build();
+ private static final GridCollidable PROPAGATE_COLLIDABLE = (me, them) -> CollisionBehavior.builder()
+ .collisionBehaviorType(CollisionBehaviorType.PROPAGATE).priority(0).build();
+
+ @Getter
+ private static class SwallowCollidable implements GridCollidable {
+ private final Set<Entity> swallowed = new HashSet<>();
+ @Override
+ public <T> void onSwallow(final Entity them, final World<T> world) {
+ swallowed.add(them);
+ }
+
+ @Override
+ public CollisionBehavior getCollisionBehaviorBetween(final Entity me, final Entity them) {
+ return CollisionBehavior.builder().collisionBehaviorType(CollisionBehaviorType.SWALLOW).priority(0).build();
+ }
+ }
+
+ @Test
+ public void testPrioritization() {
+ final World<GridInputState> world = mockWorld();
+ final GridIndexSystem indexSystem = mock(GridIndexSystem.class);
+ when(indexSystem.inBounds(any())).thenReturn(true);
+ when(world.getSystem(GridIndexSystem.class)).thenReturn(indexSystem);
+
+ final Entity pusher = Entity.builder().id(1).build().add(PROPAGATE_COLLIDABLE).add(new GridMomentum(Vec2i.EAST))
+ .add(new GridPosition(Vec2i.ZERO));
+ final Entity toPropagate = Entity.builder().id(2).build().add(PROPAGATE_COLLIDABLE)
+ .add(new GridPosition(Vec2i.EAST));
+ final Entity wall = Entity.builder().id(3).build().add(WALL_COLLIDABLE).add(new GridPosition(Vec2i.EAST));
+
+ // Propagation takes priority because priority(0) is lower
+ when(world.query(Set.of(GridMomentum.class, GridPosition.class, GridCollidable.class)))
+ .thenReturn(Set.of(pusher));
+
+ when(indexSystem.entitiesAt(Vec2i.ZERO)).thenReturn(List.of(pusher));
+ when(indexSystem.entitiesAt(Vec2i.EAST)).thenReturn(List.of(toPropagate, wall));
+
+ final GridCollisionPropagatationSystem system = new GridCollisionPropagatationSystem();
+ system.update(world, mock(GridInputState.class), FRAME);
+
+ assertEquals(Vec2i.EAST, pusher.get(GridMomentum.class).getVelocity());
+ assertEquals(Vec2i.EAST, toPropagate.get(GridMomentum.class).getVelocity());
+ }
+
+ @Test
+ public void testWallCollisionHaltsRayMomentum() {
+ final World<GridInputState> world = mockWorld();
+ final GridIndexSystem indexSystem = mock(GridIndexSystem.class);
+ when(indexSystem.inBounds(any())).thenReturn(true);
+ when(world.getSystem(GridIndexSystem.class)).thenReturn(indexSystem);
+
+ final Entity pusher = Entity.builder().id(1).build().add(PROPAGATE_COLLIDABLE).add(new GridMomentum(Vec2i.EAST))
+ .add(new GridPosition(Vec2i.ZERO));
+ final Entity toPropagate = Entity.builder().id(2).build().add(PROPAGATE_COLLIDABLE)
+ .add(new GridMomentum(Vec2i.EAST)).add(new GridPosition(Vec2i.EAST));
+ final Entity wall = Entity.builder().id(3).build().add(WALL_COLLIDABLE)
+ .add(new GridPosition(Vec2i.EAST.scale(2, 0)));
+
+ when(world.query(Set.of(GridMomentum.class, GridPosition.class, GridCollidable.class)))
+ .thenReturn(Set.of(pusher, toPropagate));
+
+ when(indexSystem.entitiesAt(Vec2i.ZERO)).thenReturn(List.of(pusher));
+ when(indexSystem.entitiesAt(Vec2i.EAST)).thenReturn(List.of(toPropagate));
+ when(indexSystem.entitiesAt(Vec2i.EAST.scale(2, 0))).thenReturn(List.of(wall));
+
+ final GridCollisionPropagatationSystem system = new GridCollisionPropagatationSystem();
+ system.update(world, GridInputState.builder().build(), FRAME);
+
+ assertEquals(Vec2i.ZERO, pusher.get(GridMomentum.class).getVelocity());
+ assertEquals(Vec2i.ZERO, toPropagate.get(GridMomentum.class).getVelocity());
+ }
+
+ @Test
+ public void testGoingOutOfBoundsHaltsMomentum() {
+ final World<GridInputState> world = mockWorld();
+ final GridIndexSystem indexSystem = mock(GridIndexSystem.class);
+
+ when(indexSystem.inBounds(any())).thenReturn(false);
+ when(world.getSystem(GridIndexSystem.class)).thenReturn(indexSystem);
+
+ final Entity pusher = Entity.builder().id(1).build().add(PROPAGATE_COLLIDABLE).add(new GridMomentum(Vec2i.EAST))
+ .add(new GridPosition(Vec2i.ZERO));
+
+ when(world.query(Set.of(GridMomentum.class, GridPosition.class, GridCollidable.class)))
+ .thenReturn(Set.of(pusher));
+
+ when(indexSystem.entitiesAt(Vec2i.ZERO)).thenReturn(List.of(pusher));
+
+ final GridCollisionPropagatationSystem system = new GridCollisionPropagatationSystem();
+ system.update(world, GridInputState.builder().build(), FRAME);
+
+ assertEquals(Vec2i.ZERO, pusher.get(GridMomentum.class).getVelocity());
+ }
+
+ @Test
+ public void testZeroVelocity() {
+ final World<GridInputState> world = mockWorld();
+ final GridIndexSystem indexSystem = mock(GridIndexSystem.class);
+
+ when(indexSystem.inBounds(any())).thenReturn(true);
+ when(world.getSystem(GridIndexSystem.class)).thenReturn(indexSystem);
+
+ final Entity pusher = Entity.builder().id(1).build().add(PROPAGATE_COLLIDABLE).add(new GridMomentum(Vec2i.ZERO))
+ .add(new GridPosition(Vec2i.ZERO));
+
+ when(world.query(Set.of(GridMomentum.class, GridPosition.class, GridCollidable.class)))
+ .thenReturn(Set.of(pusher));
+
+ when(indexSystem.entitiesAt(Vec2i.ZERO)).thenReturn(List.of(pusher));
+
+ final GridCollisionPropagatationSystem system = new GridCollisionPropagatationSystem();
+ system.update(world, GridInputState.builder().build(), FRAME);
+
+ assertEquals(Vec2i.ZERO, pusher.get(GridMomentum.class).getVelocity());
+ }
+
+ @Test
+ public void testSwallowInteraction() {
+ final World<GridInputState> world = mockWorld();
+ final GridIndexSystem indexSystem = mock(GridIndexSystem.class);
+
+ when(indexSystem.inBounds(any())).thenReturn(true);
+ when(world.getSystem(GridIndexSystem.class)).thenReturn(indexSystem);
+
+ final SwallowCollidable swallowCollidable = new SwallowCollidable();
+
+ final Entity pusher = Entity.builder().id(1).build().add(PROPAGATE_COLLIDABLE).add(new GridMomentum(Vec2i.EAST))
+ .add(new GridPosition(Vec2i.ZERO));
+
+ final Entity swallower = Entity.builder().id(2).build().add(swallowCollidable)
+ .add(new GridPosition(Vec2i.EAST));
+
+ when(world.query(Set.of(GridMomentum.class, GridPosition.class, GridCollidable.class)))
+ .thenReturn(Set.of(pusher));
+
+ when(indexSystem.entitiesAt(Vec2i.ZERO)).thenReturn(List.of(pusher));
+ when(indexSystem.entitiesAt(Vec2i.EAST)).thenReturn(List.of(swallower));
+
+ final GridCollisionPropagatationSystem system = new GridCollisionPropagatationSystem();
+ system.update(world, GridInputState.builder().build(), FRAME);
+
+ assertEquals(Vec2i.EAST, pusher.get(GridMomentum.class).getVelocity());
+ assertFalse(swallower.has(GridMomentum.class));
+
+ assertEquals(swallowCollidable.getSwallowed(), Set.of(pusher));
+ }
+
+ @Test
+ public void testDependencies() {
+ assertEquals(Set.of(GridMovementSystem.class, GridIndexSystem.class),
+ new GridCollisionPropagatationSystem().getDependencies());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static World<GridInputState> mockWorld() {
+ return (World<GridInputState>) mock(World.class);
+ }
+}
diff --git a/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridIndexSystemTest.java b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridIndexSystemTest.java
new file mode 100644
index 0000000..d9afde5
--- /dev/null
+++ b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridIndexSystemTest.java
@@ -0,0 +1,70 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.when;
+
+import coffee.liz.abstractionengine.grid.component.GridInputState;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.math.Vec2i;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.World;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.time.Duration;
+import java.util.Set;
+
+class GridIndexSystemTest {
+ @Test
+ public void testUpdateIndexesEntitiesIntoGridSlots() {
+ final GridIndexSystem system = new GridIndexSystem(new Vec2i(4, 4));
+ final World<GridInputState> world = mockWorld();
+ final Entity alpha = Entity.builder().id(11).build();
+ alpha.add(new GridPosition(new Vec2i(1, 2)));
+ final Entity beta = Entity.builder().id(12).build();
+ beta.add(new GridPosition(new Vec2i(0, 0)));
+ when(world.query(Set.of(GridPosition.class))).thenReturn(Set.of(alpha, beta));
+
+ system.update(world, GridInputState.builder().movement(Vec2i.NORTH).build(), Duration.ZERO);
+
+ assertTrue(system.entitiesAt(new Vec2i(1, 2)).contains(alpha));
+ assertTrue(system.entitiesAt(new Vec2i(0, 0)).contains(beta));
+ assertTrue(system.entitiesAt(new Vec2i(3, 3)).isEmpty());
+ }
+
+ @Test
+ public void testUpdateClearsPreviousIndexesBeforeRebuilding() {
+ final GridIndexSystem system = new GridIndexSystem(new Vec2i(2, 2));
+ final World<GridInputState> world = mockWorld();
+ final Entity moving = Entity.builder().id(77).build();
+ moving.add(new GridPosition(Vec2i.ZERO));
+ when(world.query(Set.of(GridPosition.class))).thenReturn(Set.of(moving)).thenReturn(Set.of());
+
+ system.update(world, GridInputState.builder().movement(Vec2i.EAST).build(), Duration.ZERO);
+ assertTrue(system.entitiesAt(Vec2i.ZERO).contains(moving));
+
+ system.update(world, GridInputState.builder().movement(Vec2i.NORTH).build(), Duration.ZERO);
+ assertTrue(system.entitiesAt(Vec2i.ZERO).isEmpty());
+ }
+
+ @Test
+ public void testEntitiesAtReturnsEmptySetForOutOfBoundsQuery() {
+ final GridIndexSystem system = new GridIndexSystem(new Vec2i(2, 2));
+
+ assertEquals(Set.of(), system.entitiesAt(new Vec2i(-1, 0)));
+ assertEquals(Set.of(), system.entitiesAt(new Vec2i(2, 1)));
+ assertEquals(Set.of(), system.entitiesAt(new Vec2i(1, 2)));
+ }
+
+ @Test
+ public void testDependencies() {
+ assertEquals(Set.of(), new GridIndexSystem(Vec2i.ZERO).getDependencies());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static World<GridInputState> mockWorld() {
+ return (World<GridInputState>) Mockito.mock(World.class);
+ }
+}
diff --git a/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridMovementSystemTest.java b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridMovementSystemTest.java
new file mode 100644
index 0000000..8bd8ff3
--- /dev/null
+++ b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridMovementSystemTest.java
@@ -0,0 +1,48 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.when;
+
+import coffee.liz.abstractionengine.grid.component.GridControllable;
+import coffee.liz.abstractionengine.grid.component.GridInputState;
+import coffee.liz.abstractionengine.grid.component.GridMomentum;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.math.Vec2i;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.World;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.time.Duration;
+import java.util.Set;
+
+final class GridMovementSystemTest {
+ @Test
+ public void testUpdateAssignsMomentumToControllableEntities() {
+ final World<GridInputState> world = mockWorld();
+ final Entity subject = Entity.builder().id(1).build();
+ subject.add(new GridControllable());
+ subject.add(new GridPosition(Vec2i.ZERO));
+ final Set<Entity> controllableEntities = Set.of(subject);
+ when(world.query(Set.of(GridControllable.class, GridPosition.class))).thenReturn(controllableEntities);
+
+ final GridInputState inputState = GridInputState.builder().movement(Vec2i.SOUTH).build();
+ final GridMovementSystem system = new GridMovementSystem();
+
+ system.update(world, inputState, Duration.ofMillis(16));
+
+ final GridMomentum appliedMomentum = subject.get(GridMomentum.class);
+ assertEquals(Vec2i.SOUTH, appliedMomentum.getVelocity());
+ }
+
+ @Test
+ public void testDependencies() {
+ assertEquals(Set.of(), new GridMovementSystem().getDependencies());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static World<GridInputState> mockWorld() {
+ return (World<GridInputState>) Mockito.mock(World.class);
+ }
+}
diff --git a/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystemTest.java b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystemTest.java
new file mode 100644
index 0000000..c3bb01e
--- /dev/null
+++ b/core/src/test/java/coffee/liz/abstractionengine/grid/system/GridPhysicsSystemTest.java
@@ -0,0 +1,46 @@
+package coffee.liz.abstractionengine.grid.system;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.when;
+
+import coffee.liz.abstractionengine.grid.component.GridInputState;
+import coffee.liz.abstractionengine.grid.component.GridMomentum;
+import coffee.liz.abstractionengine.grid.component.GridPosition;
+import coffee.liz.ecs.math.Vec2;
+import coffee.liz.ecs.math.Vec2i;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.World;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.time.Duration;
+import java.util.Set;
+
+final class GridPhysicsSystemTest {
+ @Test
+ public void testUpdateMovesEntitiesByMomentumAndResetsVelocity() {
+ final World<GridInputState> world = mockWorld();
+ final Entity body = Entity.builder().id(3).build();
+ body.add(new GridPosition(Vec2i.ZERO));
+ body.add(new GridMomentum(Vec2i.EAST));
+ when(world.query(Set.of(GridMomentum.class, GridPosition.class))).thenReturn(Set.of(body));
+
+ final GridPhysicsSystem system = new GridPhysicsSystem();
+ system.update(world, GridInputState.builder().movement(Vec2i.NORTH).build(), Duration.ZERO);
+
+ final Vec2<Integer> newPosition = body.get(GridPosition.class).getPosition();
+ assertEquals(Vec2i.EAST, newPosition);
+ assertEquals(Vec2i.ZERO, body.get(GridMomentum.class).getVelocity());
+ }
+
+ @Test
+ public void testDependencies() {
+ assertEquals(Set.of(GridCollisionPropagatationSystem.class), new GridPhysicsSystem().getDependencies());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static World<GridInputState> mockWorld() {
+ return (World<GridInputState>) Mockito.mock(World.class);
+ }
+}
diff --git a/core/src/test/java/coffee/liz/ecs/DAGWorldTest.java b/core/src/test/java/coffee/liz/ecs/DAGWorldTest.java
new file mode 100644
index 0000000..2f948d0
--- /dev/null
+++ b/core/src/test/java/coffee/liz/ecs/DAGWorldTest.java
@@ -0,0 +1,164 @@
+package coffee.liz.ecs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import coffee.liz.ecs.model.Component;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.System;
+import coffee.liz.ecs.model.World;
+
+import lombok.RequiredArgsConstructor;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+final class DAGWorldTest {
+ @Test
+ public void queryReturnsEntitiesMatchingAllRequestedComponents() {
+ final DAGWorld<String> world = new DAGWorld<>(Map.of());
+ final Entity matching = world.createEntity();
+ matching.add(new PositionComponent());
+ matching.add(new VelocityComponent());
+ final Entity partial = world.createEntity();
+ partial.add(new PositionComponent());
+ final Entity nonMatching = world.createEntity();
+ nonMatching.add(new VelocityComponent());
+
+ world.update("state", Duration.ZERO);
+
+ final Set<Entity> results = world.query(Set.of(PositionComponent.class, VelocityComponent.class));
+
+ assertEquals(Set.of(matching), results);
+ }
+
+ @Test
+ public void queryWithoutComponentsReturnsAllEntities() {
+ final DAGWorld<String> world = new DAGWorld<>(Map.of());
+ final Entity entityOne = world.createEntity();
+ final Entity entityTwo = world.createEntity();
+
+ final Set<Entity> results = world.query(List.<Class<? extends Component>>of());
+
+ assertEquals(Set.of(entityOne, entityTwo), results);
+ }
+
+ @Test
+ public void updateExecutesSystemsInTopologicalOrder() {
+ final CopyOnWriteArrayList<String> executionLog = new CopyOnWriteArrayList<>();
+
+ final DAGWorld<String> world = new DAGWorld<>(Map.of(SystemC.class, new SystemC(executionLog), SystemA.class,
+ new SystemA(executionLog), SystemB.class, new SystemB(executionLog)));
+ world.update("state", Duration.ZERO);
+
+ assertEquals(List.of("A", "B", "C"), executionLog);
+ }
+
+ @Test
+ public void updateRefreshesComponentCacheAfterEntityMutations() {
+ final DAGWorld<String> world = new DAGWorld<>(Map.of());
+ final Entity subject = world.createEntity();
+
+ world.update("state", Duration.ZERO);
+ assertTrue(world.query(Set.of(PositionComponent.class)).isEmpty());
+
+ subject.add(new PositionComponent());
+ world.update("state", Duration.ZERO);
+ assertEquals(1, world.query(Set.of(PositionComponent.class)).size());
+
+ subject.remove(PositionComponent.class);
+ world.update("state", Duration.ZERO);
+ assertTrue(world.query(Set.of(PositionComponent.class)).isEmpty());
+ }
+
+ @Test
+ public void circularDependencyDetectionThrowsIllegalStateException() {
+ final Map<Class<? extends System<String>>, System<String>> systems = new LinkedHashMap<>();
+ final SystemCycleA systemA = new SystemCycleA();
+ final SystemCycleB systemB = new SystemCycleB();
+ systems.put(SystemCycleA.class, systemA);
+ systems.put(SystemCycleB.class, systemB);
+
+ assertThrows(IllegalStateException.class, () -> new DAGWorld<>(systems));
+ }
+
+ private static final class PositionComponent implements Component {
+ }
+
+ private static final class VelocityComponent implements Component {
+ }
+
+ @RequiredArgsConstructor
+ private abstract static class RecordingSystem implements System<String> {
+ private final List<String> log;
+ private final String label;
+
+ @Override
+ public final void update(final World<String> world, final String state, final Duration duration) {
+ log.add(label);
+ }
+ }
+
+ private static final class SystemA extends RecordingSystem {
+ private SystemA(final List<String> log) {
+ super(log, "A");
+ }
+
+ @Override
+ public Collection<Class<? extends System<String>>> getDependencies() {
+ return List.of();
+ }
+ }
+
+ private static final class SystemB extends RecordingSystem {
+ private SystemB(final List<String> log) {
+ super(log, "B");
+ }
+
+ @Override
+ public Collection<Class<? extends System<String>>> getDependencies() {
+ return Set.of(SystemA.class);
+ }
+ }
+
+ private static final class SystemC extends RecordingSystem {
+ private SystemC(final List<String> log) {
+ super(log, "C");
+ }
+
+ @Override
+ public Collection<Class<? extends System<String>>> getDependencies() {
+ return Set.of(SystemB.class);
+ }
+ }
+
+ private static final class SystemCycleA implements System<String> {
+ @Override
+ public Collection<Class<? extends System<String>>> getDependencies() {
+ return Set.of(SystemCycleB.class);
+ }
+
+ @Override
+ public void update(final World<String> world, final String state, final Duration duration) {
+ }
+ }
+
+ private static final class SystemCycleB implements System<String> {
+ @Override
+ public Collection<Class<? extends System<String>>> getDependencies() {
+ return Set.of(SystemCycleA.class);
+ }
+
+ @Override
+ public void update(final World<String> world, final String state, final Duration duration) {
+ }
+ }
+}
diff --git a/core/src/test/java/coffee/liz/ecs/model/EntityTest.java b/core/src/test/java/coffee/liz/ecs/model/EntityTest.java
new file mode 100644
index 0000000..c9ce59a
--- /dev/null
+++ b/core/src/test/java/coffee/liz/ecs/model/EntityTest.java
@@ -0,0 +1,97 @@
+package coffee.liz.ecs.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import lombok.RequiredArgsConstructor;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Stream;
+
+final class EntityTest {
+ @ParameterizedTest
+ @MethodSource("componentCombinationProvider")
+ public void hasAllReportsPresenceForComponentSets(final Collection<Class<? extends Component>> query,
+ final boolean expected) {
+ final Entity entity = Entity.builder().id(7).build();
+ entity.add(new AlphaComponent("first"));
+ entity.add(new BetaComponent(3));
+ entity.add(new GammaKeyedComponent());
+
+ assertEquals(expected, entity.hasAll(query));
+ }
+
+ private static Stream<Arguments> componentCombinationProvider() {
+ return Stream
+ .of(Arguments.of(List.of(AlphaComponent.class), true), Arguments.of(List.of(BetaComponent.class), true),
+ Arguments.of(List.of(AlphaComponent.class, BetaComponent.class, GammaComponent.class), true),
+ Arguments.of(List.of(AlphaComponent.class, BetaComponent.class, GammaKeyedComponent.class),
+ false),
+ Arguments.of(List.of(GammaComponent.class), true),
+ Arguments.of(List.of(GammaKeyedComponent.class), false));
+ }
+
+ @Test
+ public void getThrowsForMissingComponentsWithHelpfulMessage() {
+ final Entity entity = Entity.builder().id(99).build();
+
+ final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class,
+ () -> entity.get(AlphaComponent.class));
+
+ assertTrue(thrown.getMessage().contains("AlphaComponent"));
+ assertTrue(thrown.getMessage().contains("99"));
+ }
+
+ @Test
+ public void addReplacesExistingComponentInstance() {
+ final Entity entity = Entity.builder().id(17).build();
+ final AlphaComponent initial = new AlphaComponent("initial");
+ entity.add(initial);
+
+ final AlphaComponent replacement = new AlphaComponent("replacement");
+ entity.add(replacement);
+
+ assertSame(replacement, entity.get(AlphaComponent.class));
+ }
+
+ @Test
+ public void removeClearsComponentPresence() {
+ final Entity entity = Entity.builder().id(45).build();
+ entity.add(new BetaComponent(2));
+ assertTrue(entity.has(BetaComponent.class));
+
+ entity.remove(BetaComponent.class);
+
+ assertFalse(entity.has(BetaComponent.class));
+ assertTrue(entity.componentTypes().isEmpty());
+ }
+
+ private record AlphaComponent(String name) implements Component {
+ }
+
+ private record BetaComponent(int level) implements Component {
+ }
+
+ @RequiredArgsConstructor
+ private class GammaComponent implements Component {
+ @Override
+ public Class<? extends Component> getKey() {
+ return GammaComponent.class;
+ }
+ }
+
+ private class GammaKeyedComponent extends GammaComponent {
+ }
+
+ private record ContextualComponent(int ownerId) implements Component {
+ }
+}
diff --git a/core/src/test/java/coffee/liz/lambda/eval/InterpreterTest.java b/core/src/test/java/coffee/liz/lambda/eval/InterpreterTest.java
new file mode 100644
index 0000000..4b63782
--- /dev/null
+++ b/core/src/test/java/coffee/liz/lambda/eval/InterpreterTest.java
@@ -0,0 +1,103 @@
+package coffee.liz.lambda.eval;
+
+import coffee.liz.lambda.LambdaDriver;
+import coffee.liz.lambda.ast.Expression.IdentifierExpression;
+import coffee.liz.lambda.ast.LambdaProgram;
+import coffee.liz.lambda.ast.SourceCode;
+import coffee.liz.lambda.bind.Tick;
+import coffee.liz.lambda.bind.ToChurch;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class InterpreterTest {
+
+ @Test
+ public void identity() {
+ final Value result = LambdaDriver.interpret(SourceCode.ofArrow("x -> x"));
+
+ final Value.Closure closure = assertInstanceOf(Value.Closure.class, result);
+ assertEquals("x", closure.parameter());
+ assertInstanceOf(IdentifierExpression.class, closure.body());
+ }
+
+ @Test
+ public void identityApplication() {
+ final Value result = LambdaDriver.interpret(SourceCode.ofArrow("(x -> x)(y)"));
+
+ assertEquals(new Value.Free("y"), result);
+ }
+
+ @Test
+ public void nestedApplication() {
+ final Value result = LambdaDriver.interpret(SourceCode.ofArrow("(f -> g -> x -> f(g)(x))(x -> x)(y -> y)(x)"));
+
+ assertEquals(new Value.Free("x"), result);
+ }
+
+ @Test
+ public void cons() {
+ final Value result = LambdaDriver.interpret(SourceCode.ofArrow("""
+ let pair = a -> b -> f -> f(a)(b);
+ let second = x -> y -> y;
+ pair(x)(y)(second)
+ """));
+
+ assertEquals(new Value.Free("y"), result);
+ }
+
+ @Test
+ public void fibonacci() {
+ final String source = """
+ let true = t -> f -> t;
+ let false = t -> f -> f;
+
+ let pair = x -> y -> f -> f(x)(y);
+ let fst = p -> p(x -> y -> x);
+ let snd = p -> p(x -> y -> y);
+
+ let 0 = f -> x -> x;
+ let 1 = f -> x -> f(x);
+
+ let succ = n -> f -> x -> f(n(f)(x));
+ let plus = m -> n -> f -> x -> m(f)(n(f)(x));
+ let next = p -> pair(snd(p))(succ(snd(p)));
+ let pred = n -> fst(n(next)(pair(0)(0)));
+
+ let iszero = n -> n(x -> false)(true);
+ let isone = n -> iszero(pred(n));
+
+ let y = f -> (x -> f(x(x)))(x -> f(x(x)));
+
+ let fib = y(fib -> n ->
+ iszero(n) (0)
+ (isone(n) (1)
+ (plus
+ (fib(pred(n)))
+ (fib(pred(pred(n)))))));
+
+ fib(ToChurch(13))(Tick)(dummy)
+ """;
+
+ final Tick ticker = new Tick();
+ final Value result = LambdaDriver.interpret(SourceCode.ofArrow(source), List.of(ticker, new ToChurch()));
+
+ assertEquals(new Value.Free("dummy"), result);
+ assertEquals(233, ticker.getCounter().get());
+ }
+
+ @Test
+ public void omegaCombinatorThrowsDepthExceeded() {
+ final LambdaProgram program = LambdaDriver.parse(SourceCode.ofArrow("(x -> x(x))(x -> x(x))"));
+ final Environment env = Environment.from(program.macros(), List.of());
+
+ final EvaluationDepthExceededException exception = assertThrows(EvaluationDepthExceededException.class,
+ () -> NormalOrderEvaluator.evaluate(program.expression(), env, 100));
+
+ assertEquals(100, exception.getMaxDepth());
+ }
+}
diff --git a/core/src/test/java/coffee/liz/lambda/eval/ThunkTest.java b/core/src/test/java/coffee/liz/lambda/eval/ThunkTest.java
new file mode 100644
index 0000000..2a1d5e3
--- /dev/null
+++ b/core/src/test/java/coffee/liz/lambda/eval/ThunkTest.java
@@ -0,0 +1,38 @@
+package coffee.liz.lambda.eval;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+
+public class ThunkTest {
+ @Test
+ public void testThunkNonNull() {
+ final AtomicInteger invok = new AtomicInteger(0);
+ final Supplier<Integer> i = () -> {
+ invok.incrementAndGet();
+ return invok.get();
+ };
+ final Thunk<Integer> thunk = new Thunk<>(i);
+ Assertions.assertEquals(1, thunk.get());
+ Assertions.assertEquals(1, thunk.get());
+ Assertions.assertEquals(1, thunk.get());
+ Assertions.assertEquals(1, invok.get());
+ }
+
+ @Test
+ public void testThunkNull() {
+ final AtomicInteger invok = new AtomicInteger(0);
+ final Supplier<Integer> i = () -> {
+ invok.incrementAndGet();
+ return null;
+ };
+ final Thunk<Integer> thunk = new Thunk<>(i);
+ Assertions.assertNull(thunk.get());
+ Assertions.assertNull(thunk.get());
+ Assertions.assertNull(thunk.get());
+ Assertions.assertEquals(1, invok.get());
+ }
+}
diff --git a/core/src/test/java/coffee/liz/lambda/format/FormatterTest.java b/core/src/test/java/coffee/liz/lambda/format/FormatterTest.java
new file mode 100644
index 0000000..111855f
--- /dev/null
+++ b/core/src/test/java/coffee/liz/lambda/format/FormatterTest.java
@@ -0,0 +1,53 @@
+package coffee.liz.lambda.format;
+
+import coffee.liz.lambda.LambdaDriver;
+import coffee.liz.lambda.ast.SourceCode;
+import coffee.liz.lambda.ast.SourceCode.Syntax;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class FormatterTest {
+ @ParameterizedTest
+ @MethodSource("provideRoundTrip")
+ public void testRoundTrip(final String lambda, final String arrow) {
+ final String formattedLambda = format(SourceCode.ofArrow(arrow), Syntax.LAMBDA);
+ final String formattedArrow = format(SourceCode.ofLambda(lambda), Syntax.ARROW);
+ assertEquals(lambda, formattedLambda);
+ assertEquals(arrow, formattedArrow);
+ }
+
+ public static Stream<Arguments> provideRoundTrip() {
+ return Stream.of(Arguments.of("λx.λy.x", "x -> y -> x"), Arguments.of("(λx.x) y", "(x -> x)(y)"),
+ Arguments.of("f x y z", "f(x)(y)(z)"), Arguments.of("f x y z -- Comment!", "f(x)(y)(z) -- Comment!"),
+ Arguments.of("""
+ let id = λx.x;
+ let const = λx.λy.x; -- Inline comment!
+
+ -- Test comment
+ -- Another comment
+ id""", """
+ let id = x -> x;
+ let const = x -> y -> x; -- Inline comment!
+
+ -- Test comment
+ -- Another comment
+ id"""), Arguments.of("""
+ -- The identity function
+ let id = λx.x;
+
+ id""", """
+ -- The identity function
+ let id = x -> x;
+
+ id"""));
+ }
+
+ private static String format(final SourceCode code, final Syntax syntax) {
+ return Formatter.emit(LambdaDriver.parse(code), syntax);
+ }
+}
diff --git a/core/src/test/java/coffee/liz/lambda/parser/ParserTest.java b/core/src/test/java/coffee/liz/lambda/parser/ParserTest.java
new file mode 100644
index 0000000..0158003
--- /dev/null
+++ b/core/src/test/java/coffee/liz/lambda/parser/ParserTest.java
@@ -0,0 +1,178 @@
+package coffee.liz.lambda.parser;
+
+import coffee.liz.lambda.LambdaDriver;
+import coffee.liz.lambda.ast.Expression;
+import coffee.liz.lambda.ast.Expression.AbstractionExpression;
+import coffee.liz.lambda.ast.Expression.ApplicationExpression;
+import coffee.liz.lambda.ast.Expression.IdentifierExpression;
+import coffee.liz.lambda.ast.LambdaProgram;
+import coffee.liz.lambda.ast.Macro;
+import coffee.liz.lambda.ast.SourceCode;
+import coffee.liz.lambda.ast.SourceComment;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+
+class ParserTest {
+
+ @Test
+ public void testTrivial() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("λx.x"));
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("x -> x"));
+
+ assertStructurallyEqual(lambda, arrow);
+ assertEquals(0, lambda.macros().size());
+ assertInstanceOf(AbstractionExpression.class, lambda.expression());
+ assertEquals("x", ((AbstractionExpression) lambda.expression()).parameter());
+ }
+
+ @Test
+ public void testApplication() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("(λx.x) y"));
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("(x -> x)(y)"));
+
+ assertStructurallyEqual(lambda, arrow);
+
+ final ApplicationExpression app = (ApplicationExpression) lambda.expression();
+ assertInstanceOf(AbstractionExpression.class, app.applicable());
+ assertInstanceOf(IdentifierExpression.class, app.argument());
+ }
+
+ @Test
+ public void testChainedLambdas() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("λx.λy.λz.x"));
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("x -> y -> z -> x"));
+
+ assertStructurallyEqual(lambda, arrow);
+
+ final AbstractionExpression outer = (AbstractionExpression) lambda.expression();
+ assertEquals("x", outer.parameter());
+ final AbstractionExpression middle = (AbstractionExpression) outer.body();
+ assertEquals("y", middle.parameter());
+ final AbstractionExpression inner = (AbstractionExpression) middle.body();
+ assertEquals("z", inner.parameter());
+ }
+
+ @Test
+ public void testChainedApplication() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("((f x) y) z"));
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("f(x)(y)(z)"));
+
+ assertStructurallyEqual(lambda, arrow);
+
+ final ApplicationExpression app1 = (ApplicationExpression) lambda.expression();
+ assertEquals("z", ((IdentifierExpression) app1.argument()).name());
+ final ApplicationExpression app2 = (ApplicationExpression) app1.applicable();
+ assertEquals("y", ((IdentifierExpression) app2.argument()).name());
+ final ApplicationExpression app3 = (ApplicationExpression) app2.applicable();
+ assertEquals("f", ((IdentifierExpression) app3.applicable()).name());
+ assertEquals("x", ((IdentifierExpression) app3.argument()).name());
+ }
+
+ @Test
+ public void testMacros() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("""
+ let id = λx.x;
+ id
+ """));
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("""
+ let id = x -> x;
+ id
+ """));
+
+ assertStructurallyEqual(lambda, arrow);
+ assertEquals(1, lambda.macros().size());
+ assertEquals("id", lambda.macros().getFirst().name());
+ assertInstanceOf(IdentifierExpression.class, lambda.expression());
+ }
+
+ @Test
+ public void testLineComments() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("""
+ -- The identity function
+ let id = λx.x; -- returns its argument
+ id -- use it
+ """));
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("""
+ -- The identity function
+ let id = x -> x; -- returns its argument
+ id -- use it
+ """));
+
+ assertStructurallyEqual(lambda, arrow);
+ assertEquals(1, lambda.macros().size());
+ assertEquals("id", lambda.macros().getFirst().name());
+ }
+
+ @Test
+ public void testComplexProgram() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("""
+ let zero = λf.λx.x;
+ let one = λf.λx.f x;
+ let succ = λn.λf.λx.f (n f x);
+ let add = λm.λn.λf.λx.m f (n f x);
+
+ succ (add one zero)
+ """));
+
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("""
+ let zero = f -> x -> x;
+ let one = f -> x -> f(x);
+ let succ = n -> f -> x -> f(n(f)(x));
+ let add = m -> n -> f -> x -> m(f)(n(f)(x));
+
+ succ(add(one)(zero))
+ """));
+
+ assertStructurallyEqual(lambda, arrow);
+
+ assertEquals(4, lambda.macros().size());
+ assertEquals("zero", lambda.macros().get(0).name());
+ assertEquals("one", lambda.macros().get(1).name());
+ assertEquals("succ", lambda.macros().get(2).name());
+ assertEquals("add", lambda.macros().get(3).name());
+ }
+
+ @Test
+ public void testOmegaCombinator() {
+ final LambdaProgram lambda = LambdaDriver.parse(SourceCode.ofLambda("(λx.x x)(λx.x x)"));
+ final LambdaProgram arrow = LambdaDriver.parse(SourceCode.ofArrow("(x -> x(x))(x -> x(x))"));
+
+ assertStructurallyEqual(lambda, arrow);
+ }
+
+ private static void assertStructurallyEqual(final LambdaProgram expected, final LambdaProgram actual) {
+ assertEquals(expected.macros().size(), actual.macros().size(), "Macro count mismatch");
+ for (int i = 0; i < expected.macros().size(); i++) {
+ assertStructurallyEqual(expected.macros().get(i), actual.macros().get(i));
+ }
+ assertStructurallyEqual(expected.expression(), actual.expression());
+ }
+
+ private static void assertStructurallyEqual(final Macro expected, final Macro actual) {
+ assertEquals(expected.name(), actual.name(), "Macro name mismatch");
+ assertEquals(expected.comment().map(SourceComment::text), actual.comment().map(SourceComment::text),
+ "Macro comment mismatch");
+ assertStructurallyEqual(expected.expression(), actual.expression());
+ }
+
+ private static void assertStructurallyEqual(final Expression expected, final Expression actual) {
+ assertEquals(expected.getClass(), actual.getClass(), "Expression type mismatch");
+ assertEquals(expected.comment().map(SourceComment::text), actual.comment().map(SourceComment::text),
+ "Expression comment mismatch");
+
+ switch (expected) {
+ case IdentifierExpression e ->
+ assertEquals(e.name(), ((IdentifierExpression) actual).name(), "Identifier name mismatch");
+ case AbstractionExpression e -> {
+ assertEquals(e.parameter(), ((AbstractionExpression) actual).parameter(), "Parameter mismatch");
+ assertStructurallyEqual(e.body(), ((AbstractionExpression) actual).body());
+ }
+ case ApplicationExpression e -> {
+ assertStructurallyEqual(e.applicable(), ((ApplicationExpression) actual).applicable());
+ assertStructurallyEqual(e.argument(), ((ApplicationExpression) actual).argument());
+ }
+ }
+ }
+}