Skip to content

Commit 5ffc240

Browse files
hopshackleclaude
andcommitted
Ventlife: Octopus retreats to the end of the line; Smokers open to all but Fish
Designer, 2026-10-11: - A displaced Octopus goes in a straight line to its end, the last hex before one with no tile in the direction of travel; if that hex is taken (or on the new tile) the direction cannot be used. Edge hexes part-way along no longer count. - Every species but Fish may be placed on, or displaced onto, a Black Smoker, within its own rule (Snails are still placed only on Basalt Ridge). The smokersOnlyForWormsAndShrimp parameter is removed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 12cf1f0 commit 5ffc240

13 files changed

Lines changed: 93 additions & 182 deletions

‎data/rules/Ventlife/1-rules.md‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,7 @@ Creatures under the new tile move or return to their owner's supply. Your own cr
3434
- If there is one, it moves there.
3535
- If there are several, its owner chooses one.
3636

37-
<!-- if smokersOnlyForWormsAndShrimp -->
38-
Only Tube Worms and Shrimp may be placed on, or moved onto, a Black Smoker.
39-
<!-- else -->
40-
Fish may not be placed on, or moved onto, a Black Smoker. Every other species may.
41-
<!-- end -->
37+
Fish may not be placed on, or moved onto, a Black Smoker. Every other species may, within its own rule: Snails are placed only on Basalt Ridge, but may move onto a Black Smoker.
4238

4339
### Species
4440

@@ -49,7 +45,7 @@ Fish may not be placed on, or moved onto, a Black Smoker. Every other species ma
4945
| Blind Vent Shrimp (Sh) | Any hex but Basalt Ridge. | To any hex but Basalt Ridge. | {shrimpPointsOneTerrain} next to Diffuse Vents or Microbial Mat, {shrimpPointsBothTerrains} next to both. |
5046
| Eelpout Fish (F) | A level-1 hex, or a hex next to one of your Fish. | To a hex of the same or a lower level. | Each shoal of 2 or more connected Fish scores {shoalPoints} for each Fish after the first. |
5147
| Yeti Crabs (C) | A hex of level 2 or more. | To a hex of the same level. | On each plateau (2 or more connected hexes of the same level), the player with the most Crabs scores its size, and the player with the second most scores half of it (rounded down). Players tied for the most all score the size, and no second place is scored. |
52-
| Deep-Sea Octopus (O) | An edge hex (next to empty seafloor). | Its owner chooses a direction; it moves in a straight line to the first empty edge hex. | {octopusPointsPerSpecies} for each other species next to it. |
48+
| Deep-Sea Octopus (O) | An edge hex (next to empty seafloor). | Its owner chooses a direction; it moves in a straight line, over anything in its way, to the end of the line: the last hex before one with no tile. If that hex is taken (or on the new tile), it cannot go that way. Edge hexes along the way don't count. | {octopusPointsPerSpecies} for each other species next to it. |
5349
| Vent Sponges (Sp) | Any hex. | They return. | {spongePointsPerTerrain} for each terrain next to them. |
5450

5551
### End

‎src/main/java/games/ventlife/VentlifeParameters.java‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ private record TileKind(Terrain left, Terrain right, int[] quantity) {
2929
public SpeciesSelection speciesSelection = SpeciesSelection.RANDOM;
3030
public int nSpecies = 4;
3131
public int tokensPerSpecies = 6;
32-
// only Tube Worms and Shrimp may be on a Black Smoker (the Habitat Guide); if false, only Fish are kept off them
33-
public boolean smokersOnlyForWormsAndShrimp = true;
3432
public int lowVentBonus = 1;
3533
public int maxSnailsPerTurn = 3;
3634
public int snailPointsPerHeightEdge = 1;
@@ -46,7 +44,6 @@ public VentlifeParameters() {
4644
addTunableParameter("speciesSelection", SpeciesSelection.RANDOM, Arrays.asList(SpeciesSelection.values()));
4745
addTunableParameter("nSpecies", 4, Arrays.asList(3, 4, 5));
4846
addTunableParameter("tokensPerSpecies", 6, Arrays.asList(4, 5, 6, 7, 8));
49-
addTunableParameter("smokersOnlyForWormsAndShrimp", true, Arrays.asList(true, false));
5047
addTunableParameter("lowVentBonus", 1, Arrays.asList(0, 1, 2));
5148
addTunableParameter("maxSnailsPerTurn", 3, Arrays.asList(1, 2, 3));
5249
addTunableParameter("snailPointsPerHeightEdge", 1);
@@ -64,7 +61,6 @@ public void _reset() {
6461
speciesSelection = (SpeciesSelection) getParameterValue("speciesSelection");
6562
nSpecies = (int) getParameterValue("nSpecies");
6663
tokensPerSpecies = (int) getParameterValue("tokensPerSpecies");
67-
smokersOnlyForWormsAndShrimp = (boolean) getParameterValue("smokersOnlyForWormsAndShrimp");
6864
lowVentBonus = (int) getParameterValue("lowVentBonus");
6965
maxSnailsPerTurn = (int) getParameterValue("maxSnailsPerTurn");
7066
snailPointsPerHeightEdge = (int) getParameterValue("snailPointsPerHeightEdge");

‎src/main/java/games/ventlife/components/Species.java‎

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,17 @@ public enum Species {
2222
public boolean canPlace(VentlifeGameState state, int player, Hex hex) {
2323
if (!state.isEmptyHex(hex))
2424
return false;
25+
// Black Smokers are open to every species but Fish (designer, 2026-10-11)
2526
Terrain terrain = state.getCell(hex).terrain();
26-
boolean smokerOk = terrain != Terrain.BLACK_SMOKER || mayUseSmokers(state);
2727
return switch (this) {
2828
case TUBE_WORM -> terrain == Terrain.BLACK_SMOKER;
29-
case VENT_SPONGE -> smokerOk;
29+
case VENT_SPONGE -> true;
3030
case VENT_SHRIMP -> terrain != Terrain.BASALT_RIDGE;
3131
case EELPOUT_FISH -> terrain != Terrain.BLACK_SMOKER
3232
&& (state.getLevel(hex) == 1 || nextToOwnFish(state, player, hex));
33-
case YETI_CRAB -> smokerOk && state.getLevel(hex) >= 2;
33+
case YETI_CRAB -> state.getLevel(hex) >= 2;
3434
case VOLCANO_SNAIL -> terrain == Terrain.BASALT_RIDGE;
35-
case OCTOPUS -> smokerOk && state.isEdge(hex);
35+
case OCTOPUS -> state.isEdge(hex);
3636
};
3737
}
3838

@@ -69,8 +69,9 @@ public List<Hex> destinations(VentlifeGameState state, Hex from, int fromLevel,
6969
boolean ok = switch (this) {
7070
case VENT_SHRIMP -> terrain != Terrain.BASALT_RIDGE;
7171
case EELPOUT_FISH -> terrain != Terrain.BLACK_SMOKER && level <= fromLevel;
72-
case YETI_CRAB -> (terrain != Terrain.BLACK_SMOKER || mayUseSmokers(state)) && level == fromLevel;
73-
case VOLCANO_SNAIL -> (terrain != Terrain.BLACK_SMOKER || mayUseSmokers(state)) && level < fromLevel;
72+
case YETI_CRAB -> level == fromLevel;
73+
// placed only on Basalt Ridge, but may move onto any terrain
74+
case VOLCANO_SNAIL -> level < fromLevel;
7475
// Tube Worms not on the extended Smoker, and Vent Sponges, always return
7576
default -> false;
7677
};
@@ -85,23 +86,16 @@ public List<Hex> destinations(VentlifeGameState state, Hex from, int fromLevel,
8586
* Where an Octopus retreating from from in the direction stops, or null if it cannot go that way.
8687
*/
8788
private Hex retreat(VentlifeGameState state, Hex from, int direction, int newTile) {
88-
// along the straight line, passing over everything but an empty Edge hex it may use (not on the new tile); the
89-
// direction fails if the line leaves the field first
89+
// along the straight line, passing over everything, to its end: the last hex before one with no tile in the
90+
// direction of travel (designer, 2026-10-11). Edge hexes along the way, with empty seafloor only to the side,
91+
// do not count. The direction fails if that end hex is taken or on the new tile, or if there is no tile at
92+
// all next to the covered position in that direction
9093
Hex h = from.neighbour(direction);
91-
while (state.getCell(h) != null) {
92-
if (state.isEmptyHex(h) && state.isEdge(h) && state.getCell(h).tileId() != newTile
93-
&& (state.getCell(h).terrain() != Terrain.BLACK_SMOKER || mayUseSmokers(state)))
94-
return h;
94+
if (state.getCell(h) == null)
95+
return null;
96+
while (state.getCell(h.neighbour(direction)) != null)
9597
h = h.neighbour(direction);
96-
}
97-
return null;
98-
}
99-
100-
/**
101-
* Whether this species may be on a Black Smoker, where its own rule does not say.
102-
*/
103-
private boolean mayUseSmokers(VentlifeGameState state) {
104-
return this == TUBE_WORM || this == VENT_SHRIMP || !state.getParams().smokersOnlyForWormsAndShrimp;
98+
return state.isEmptyHex(h) && state.getCell(h).tileId() != newTile ? h : null;
10599
}
106100

107101
/**

‎src/test/java/games/fmtester/ForwardModelTestsWithMCTS.java‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -555,10 +555,9 @@ public void testWarOfTheToads() {
555555
@Test
556556
public void testVentlife() {
557557
new ForwardModelTester("game=Ventlife", "nGames=1", "nPlayers=2", "agent=json\\players\\mcts.json");
558-
// the draft, with Black Smokers open to every species but Fish
558+
// the draft
559559
VentlifeParameters params = new VentlifeParameters();
560560
params.setParameterValue("speciesSelection", VentlifeParameters.SpeciesSelection.DRAFT);
561-
params.setParameterValue("smokersOnlyForWormsAndShrimp", false);
562561
new ForwardModelTester(params, "game=Ventlife", "nGames=1", "nPlayers=3", "agent=json\\players\\mcts.json");
563562
}
564563
}

‎src/test/java/games/fmtester/ForwardModelTestsWithRandom.java‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,10 +525,9 @@ public void testWarOfTheToads() {
525525
public void testVentlife() {
526526
new ForwardModelTester("game=Ventlife", "nGames=1", "nPlayers=2");
527527
new ForwardModelTester("game=Ventlife", "nGames=1", "nPlayers=4");
528-
// the draft, with Black Smokers open to every species but Fish
528+
// the draft
529529
VentlifeParameters params = new VentlifeParameters();
530530
params.setParameterValue("speciesSelection", VentlifeParameters.SpeciesSelection.DRAFT);
531-
params.setParameterValue("smokersOnlyForWormsAndShrimp", false);
532531
new ForwardModelTester(params, "game=Ventlife", "nGames=1", "nPlayers=3");
533532
}
534533
}

‎src/test/java/games/ventlife/VentlifeCoveringTest.java‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,8 @@ public void coveredSpongesOfEveryPlayerReturnToTheirOwnersSupply() {
9292

9393
@Test
9494
public void aSpongeUnderTheNewSmokerReturnsRatherThanClimbing() {
95-
VentlifeParameters params = new VentlifeParameters();
96-
params.setParameterValue("smokersOnlyForWormsAndShrimp", false);
97-
state = newState(2, 42, params);
9895
useSpecies(state, SPONGE);
99-
// sponges on both seafloor Smokers (allowed with the restriction off)
96+
// sponges on both seafloor Smokers (open to Sponges)
10097
place(state, fm, BASALT, DIFFUSE, hex(0, 0), 1, sponge(hex(0, 0)));
10198
place(state, fm, DIFFUSE, MAT, hex(1, 0), 0, sponge(hex(1, 0)));
10299
// tile M/B at ((0,0),0): Smoker on (0,0) [player 0's sponge], right E (1,0) [player 1's sponge]

‎src/test/java/games/ventlife/VentlifeCreaturePlacementTest.java‎

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ public void afterTheTileTheSamePlayerChoosesAmongEveryLegalCreaturePlacement() {
4646
// the draw comes at the end of the turn: not yet (36 tiles - 2 in hands)
4747
assertEquals(0, state.getHand(0).getSize());
4848
assertEquals(34, state.getDrawDeck().getSize());
49-
// worms on the empty level-1 Smoker, 1 or 1 + lowVentBonus (1); sponges on the two empty non-Smoker hexes
49+
// worms on the empty level-1 Smoker, 1 or 1 + lowVentBonus (1); sponges on any of the three hexes
5050
Set<AbstractAction> expected = Set.of(worm(hex(0, 0), 1), worm(hex(0, 0), 2),
51-
sponge(hex(-1, 1)), sponge(hex(0, 1)));
51+
sponge(hex(0, 0)), sponge(hex(-1, 1)), sponge(hex(0, 1)));
5252
assertEquals(expected, legalSet(state, fm));
5353
}
5454

@@ -97,10 +97,10 @@ public void occupiedHexesAreNotOfferedToEitherSpecies() {
9797
// player 0: tile B/M at ((-1,0),2) -> (-1,0) S, (-2,0) B, (-2,1) M, all level 1
9898
giveTile(state, 0, BASALT, MAT);
9999
fm.next(state, placeTile(state, hex(-1, 0), 2));
100-
// occupied: (0,0) worm, (0,1) sponge. Empty Smokers (1,0), (-1,0) (level 1: count 1 or 2);
101-
// empty non-Smokers (-1,1), (1,1), (2,0), (-2,0), (-2,1)
100+
// occupied: (0,0) worm, (0,1) sponge. Worms on the empty Smokers (1,0), (-1,0) (level 1: count 1 or 2);
101+
// sponges on those and the empty non-Smokers (-1,1), (1,1), (2,0), (-2,0), (-2,1)
102102
Set<AbstractAction> expected = Set.of(worm(hex(1, 0), 1), worm(hex(1, 0), 2),
103-
worm(hex(-1, 0), 1), worm(hex(-1, 0), 2),
103+
worm(hex(-1, 0), 1), worm(hex(-1, 0), 2), sponge(hex(1, 0)), sponge(hex(-1, 0)),
104104
sponge(hex(-1, 1)), sponge(hex(1, 1)), sponge(hex(2, 0)), sponge(hex(-2, 0)), sponge(hex(-2, 1)));
105105
assertEquals(expected, legalSet(state, fm));
106106
}
@@ -156,14 +156,7 @@ public void bonusIsLimitedByTheWormsLeftInTheSupply() {
156156
}
157157

158158
@Test
159-
public void spongesGoOnBlackSmokersOnlyWhenTheHabitatRestrictionIsOff() {
160-
useSpecies(state, SPONGE);
161-
placeFirstTile();
162-
assertEquals(Set.of(sponge(hex(-1, 1)), sponge(hex(0, 1))), legalSet(state, fm));
163-
164-
VentlifeParameters params = new VentlifeParameters();
165-
params.setParameterValue("smokersOnlyForWormsAndShrimp", false);
166-
newStateWith(params);
159+
public void spongesGoOnAnyEmptyHexBlackSmokersIncluded() {
167160
useSpecies(state, SPONGE);
168161
placeFirstTile();
169162
assertEquals(Set.of(sponge(hex(0, 0)), sponge(hex(-1, 1)), sponge(hex(0, 1))), legalSet(state, fm));

‎src/test/java/games/ventlife/VentlifeDisplacementTest.java‎

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,6 @@ public void setup() {
3232
fm = new VentlifeForwardModel();
3333
}
3434

35-
private static VentlifeParameters smokersForAll() {
36-
VentlifeParameters params = new VentlifeParameters();
37-
params.setParameterValue("smokersOnlyForWormsAndShrimp", false);
38-
return params;
39-
}
40-
4135
/** Tiles 0 and 1 with no species in play, then only the given species; player 0 to place a tile. */
4236
private void seafloorPair(Species... species) {
4337
useSpecies(state);
@@ -205,13 +199,12 @@ public void ownerOfACoveredFishChoosesAmongHexesOfTheSameOrALowerLevel() {
205199
}
206200

207201
@Test
208-
public void aCoveredFishStaysOffSmokersEvenWhenSmokersAreOpenToEverySpecies() {
209-
state = newState(2, 42, smokersForAll());
202+
public void aCoveredFishStaysOffSmokers() {
210203
standardFieldBeforeTile5(FISH);
211204
putCreature(state, hex(-1, 1), FISH, 0, 1);
212205
placeTile5();
213206
// (-1,1) B2 is under the new left hex; neighbours: (-2,1) D2 (same level), (-1,0) S2 (same level, but a
214-
// Smoker, and Fish never go on Smokers, whatever the parameter), (0,1) D3 and (0,0) S3 (new, higher),
207+
// Smoker, and Fish never go on Smokers), (0,1) D3 and (0,0) S3 (new, higher),
215208
// (-1,2) and (-2,2) uncovered -> the single destination (-2,1), with no decision
216209
assertEquals(Map.of(hex(-2, 1), new Creature(FISH, 0, 1)), state.getCreatures());
217210
assertNoCoveringDecision(state);
@@ -222,25 +215,7 @@ public void aCoveredFishStaysOffSmokersEvenWhenSmokersAreOpenToEverySpecies() {
222215
}
223216

224217
@Test
225-
public void coveredCrabsMoveToTheirOnlyHexOfTheSameLevelThatIsNotASmoker() {
226-
standardFieldBeforeTile5(CRAB);
227-
putCreature(state, hex(0, 1), CRAB, 0, 1);
228-
putCreature(state, hex(-1, 1), CRAB, 1, 1);
229-
placeTile5();
230-
// player 1's crab (placer) under the left hex (-1,1) B2: neighbours (-2,1) D2, (-1,0) S2 (Smoker),
231-
// (0,1) and (0,0) now level 3 -> (-2,1).
232-
// player 0's crab under the right hex (0,1) M2: (1,0) B2, (1,1) D1, (-1,1) and (0,0) level 3 -> (1,0)
233-
assertEquals(Map.of(hex(-2, 1), new Creature(CRAB, 1, 1), hex(1, 0), new Creature(CRAB, 0, 1)),
234-
state.getCreatures());
235-
assertCreatureStepOf(1);
236-
// empty, level >= 2, not a Smoker: (-1,1) M3, (0,1) D3
237-
assertEquals(Set.of(crab(hex(-1, 1)), crab(hex(0, 1))), legalSet(state, fm));
238-
assertTokensConserved(state, "after the displacement");
239-
}
240-
241-
@Test
242-
public void aCoveredCrabMayChooseASmokerOfItsLevelWhenSmokersAreOpenToEverySpecies() {
243-
state = newState(2, 42, smokersForAll());
218+
public void aCoveredCrabMayChooseASmokerOfItsLevel() {
244219
standardFieldBeforeTile5(CRAB);
245220
putCreature(state, hex(0, 1), CRAB, 0, 1);
246221
putCreature(state, hex(-1, 1), CRAB, 1, 1);
@@ -262,7 +237,6 @@ public void aCoveredCrabMayChooseASmokerOfItsLevelWhenSmokersAreOpenToEverySpeci
262237

263238
@Test
264239
public void thePlacersCreatureTakesTheOnlyFreeHexAndAnEarlierSeatedPlayersCreatureReturns() {
265-
state = newState(2, 42, smokersForAll());
266240
standardFieldBeforeTile5(CRAB);
267241
putCreature(state, hex(0, 0), CRAB, 0, 1);
268242
putCreature(state, hex(-1, 0), CRAB, 0, 1);

‎src/test/java/games/ventlife/VentlifeGameFlowTest.java‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -356,13 +356,14 @@ public void aRandomGameAtTheDefaultParametersEndsOnlyWhenEveryTileIsPlaced() {
356356

357357
@Test
358358
public void tilesAndCreatureTokensAreConservedThroughARandomGame() {
359-
// seed 26 gives displacement decisions and turns with several Snails
360-
Game game = newGame(3, 26);
359+
// a game that has a displacement decision and a turn of several Snails (most seeds do)
360+
long seed = 5;
361+
Game game = newGame(3, seed);
361362
VentlifeGameState state = (VentlifeGameState) game.getGameState();
362363
AbstractForwardModel fm = game.getForwardModel();
363364
// every species in play, so every placement and covering rule is exercised
364365
useSpecies(state, Species.values());
365-
Random rnd = new Random(26);
366+
Random rnd = new Random(seed);
366367
int steps = 0, plateaus = 0, creaturesPlaced = 0, returns = 0, decisions = 0, multiSnailTurns = 0;
367368
// the Volcano Snails placed in the current turn, in order
368369
List<Hex> snailsThisTurn = new ArrayList<>();
@@ -378,8 +379,12 @@ public void tilesAndCreatureTokensAreConservedThroughARandomGame() {
378379
// half the time, prefer a plateau (Smoker on a covered position) when one is offered
379380
List<AbstractAction> onTop = actions.stream()
380381
.filter(a -> a instanceof PlaceTile pt && state.getCell(pt.smoker) != null).toList();
381-
AbstractAction action = !onTop.isEmpty() && rnd.nextBoolean()
382-
? onTop.get(rnd.nextInt(onTop.size())) : actions.get(rnd.nextInt(actions.size()));
382+
// and half the time, place another Snail when one may follow those placed this turn
383+
List<AbstractAction> moreSnails = snailsThisTurn.isEmpty() ? List.of() : actions.stream()
384+
.filter(a -> a instanceof PlaceCreature pc && pc.species == SNAIL).toList();
385+
AbstractAction action = !onTop.isEmpty() && rnd.nextBoolean() ? onTop.get(rnd.nextInt(onTop.size()))
386+
: !moreSnails.isEmpty() && rnd.nextBoolean() ? moreSnails.get(rnd.nextInt(moreSnails.size()))
387+
: actions.get(rnd.nextInt(actions.size()));
383388
if (onTop.contains(action))
384389
plateaus++;
385390
if (action instanceof PlaceCreature pc && pc.species == SNAIL) {
@@ -418,16 +423,12 @@ public void tilesAndCreatureTokensAreConservedThroughARandomGame() {
418423
assertEquals("worm off a Smoker at " + e.getKey(), SMOKER, cell.terrain());
419424
else
420425
assertEquals("stacked " + where, 1, c.count());
421-
// placement and displacement keep each species on its terrain (default: Smokers for worms and
422-
// shrimp only); crabs only ever move between hexes of one level >= 2
426+
// placement and displacement keep each species on its terrain (Smokers for all but Fish); crabs only
427+
// ever move between hexes of one level >= 2
423428
switch (c.species()) {
424429
case VENT_SHRIMP -> assertNotEquals(where, BASALT, cell.terrain());
425-
case EELPOUT_FISH, VENT_SPONGE, VOLCANO_SNAIL, OCTOPUS ->
426-
assertNotEquals(where, SMOKER, cell.terrain());
427-
case YETI_CRAB -> {
428-
assertNotEquals(where, SMOKER, cell.terrain());
429-
assertTrue(where, cell.level() >= 2);
430-
}
430+
case EELPOUT_FISH -> assertNotEquals(where, SMOKER, cell.terrain());
431+
case YETI_CRAB -> assertTrue(where, cell.level() >= 2);
431432
default -> {
432433
}
433434
}

0 commit comments

Comments
 (0)