Merge origin/dev into feature/cities-and-knights
CI / Build and check (pull_request) Successful in 8m3s
CI / Deploy to Cloudflare Pages (pull_request) Skipped

Brings in open trade offers and counter-offers. Cities & Knights gets the same
flow for goods: OfferGoods may be open to the table, CounterGoods answers an
offer, and AcceptCounter / DismissCounters are shared with the base game.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
This commit is contained in:
Claude
2026-10-17 14:45:39 +00:00
18 changed files with 898 additions and 101 deletions
@@ -142,4 +142,50 @@ class CatanAiTest {
assertEquals(CatanPhase.PLAYING, after.phase)
assertNull(after.pendingTrade)
}
private fun tradeTable(offer: TradeOffer, vararg hands: Map<Resource, Int>): CatanState =
CatanRules.initialState(config(CatanOptions(), seed = 7L)).copy(
phase = CatanPhase.TRADE_PENDING,
turn = 0,
turnNumber = 1,
pendingTrade = offer,
resources = hands.toList(),
)
@Test
fun `the AI accepts an offer that gives it more than it costs, and declines the reverse`() {
val generous = tradeTable(
TradeOffer(by = 0, to = null, give = listOf(Resource.ORE, Resource.ORE), get = listOf(Resource.WOOL), asking = 1),
hand(Resource.ORE to 2), hand(Resource.WOOL to 2), hand(),
)
assertEquals(AcceptTrade, CatanAi().chooseMove(generous, 1, CatanRules.legalMoves(generous, 1)))
val stingy = tradeTable(
TradeOffer(by = 0, to = null, give = listOf(Resource.WOOL), get = listOf(Resource.ORE), asking = 1),
hand(Resource.WOOL to 1), hand(Resource.ORE to 2), hand(),
)
assertEquals(DeclineTrade, CatanAi().chooseMove(stingy, 1, CatanRules.legalMoves(stingy, 1)))
}
@Test
fun `an AI offerer takes the best counter and refuses a losing one`() {
val counters = listOf(
TradeCounter(by = 1, give = listOf(Resource.ORE), get = listOf(Resource.LUMBER)),
TradeCounter(by = 2, give = listOf(Resource.ORE, Resource.GRAIN), get = listOf(Resource.WOOL)),
)
val state = tradeTable(
TradeOffer(by = 0, to = null, give = listOf(Resource.WOOL), get = listOf(Resource.ORE), asking = -1, counters = counters),
hand(Resource.LUMBER to 1, Resource.WOOL to 1), hand(Resource.ORE to 1), hand(Resource.ORE to 1, Resource.GRAIN to 1),
).copy(phase = CatanPhase.TRADE_COUNTERS)
val legal = CatanRules.legalMoves(state, 0)
assertEquals(AcceptCounter(2), CatanAi().chooseMove(state, 0, legal))
val bad = state.copy(
pendingTrade = state.pendingTrade!!.copy(
counters = listOf(TradeCounter(by = 1, give = listOf(Resource.WOOL), get = listOf(Resource.LUMBER))),
),
resources = listOf(hand(Resource.LUMBER to 1), hand(Resource.WOOL to 1), hand()),
)
assertEquals(DismissCounters, CatanAi().chooseMove(bad, 0, CatanRules.legalMoves(bad, 0)))
}
}
@@ -912,6 +912,68 @@ class CatanCkRulesTest {
assertNull(s.ck!!.goodsOffer)
}
@Test
fun `an open goods offer is asked of each seat in turn and the first to accept takes it`() {
var s = table().hand(0, Good.CLOTH to 2).hand(1, Good.ORE to 1).hand(2, Good.ORE to 1)
s = apply(s, 0, OfferGoods(null, listOf(Good.CLOTH, Good.CLOTH), listOf(Good.ORE)))
assertEquals(CatanPhase.TRADE_PENDING, s.phase)
assertEquals("the seat after the offerer is asked first", 1, CatanRules.currentSeat(s))
s = apply(s, 1, DeclineTrade)
assertEquals(2, CatanRules.currentSeat(s))
s = apply(s, 2, AcceptTrade)
assertEquals(CatanPhase.PLAYING, s.phase)
assertEquals(2, s.ck!!.commodities[2][Commodity.CLOTH])
assertEquals(1, s.resources[0][Resource.ORE])
assertEquals("seat 1 was never asked a second time and keeps its ore", 1, s.resources[1][Resource.ORE])
assertNull(s.ck!!.goodsOffer)
}
@Test
fun `an open goods offer nobody takes ends with a word and no change`() {
var s = table().hand(0, Good.CLOTH to 2).hand(1, Good.ORE to 1).hand(2, Good.ORE to 1)
s = apply(s, 0, OfferGoods(null, listOf(Good.CLOTH, Good.CLOTH), listOf(Good.ORE)))
s = apply(s, 1, DeclineTrade)
s = apply(s, 2, DeclineTrade)
assertEquals(CatanPhase.PLAYING, s.phase)
assertTrue(s.log.last().contains("Nobody takes the offer"))
assertEquals(2, s.ck!!.commodities[0][Commodity.CLOTH])
}
@Test
fun `a seat may counter a goods offer and the offerer takes the counter or none`() {
var s = table().hand(0, Good.CLOTH to 2).hand(1, Good.ORE to 1).hand(2, Good.ORE to 1)
s = apply(s, 0, OfferGoods(null, listOf(Good.CLOTH, Good.CLOTH), listOf(Good.ORE)))
// Seat 1 would swap one for one; taking the offer as made is Accept, not a counter.
assertFalse(
CatanRules.isLegalMove(s, 1, CounterGoods(listOf(Good.ORE), listOf(Good.CLOTH, Good.CLOTH))),
)
s = apply(s, 1, CounterGoods(listOf(Good.ORE), listOf(Good.CLOTH)))
s = apply(s, 2, DeclineTrade)
assertEquals(CatanPhase.TRADE_COUNTERS, s.phase)
assertEquals(0, CatanRules.currentSeat(s))
assertEquals(
setOf<CatanMove>(AcceptCounter(1), DismissCounters),
CatanRules.legalMoves(s, 0).toSet(),
)
val taken = apply(s, 0, AcceptCounter(1))
assertEquals(CatanPhase.PLAYING, taken.phase)
assertEquals(1, taken.ck!!.commodities[0][Commodity.CLOTH])
assertEquals(1, taken.resources[0][Resource.ORE])
assertEquals(1, taken.ck!!.commodities[1][Commodity.CLOTH])
assertEquals(0, taken.resources[1][Resource.ORE])
val refused = apply(s, 0, DismissCounters)
assertEquals(CatanPhase.PLAYING, refused.phase)
assertEquals(2, refused.ck!!.commodities[0][Commodity.CLOTH])
}
@Test
fun `an open offer skips seats with no cards at all`() {
var s = table().hand(0, Good.CLOTH to 2).hand(1).hand(2, Good.ORE to 1)
s = apply(s, 0, OfferGoods(null, listOf(Good.CLOTH, Good.CLOTH), listOf(Good.ORE)))
assertEquals(2, CatanRules.currentSeat(s))
}
@Test
fun `thirteen points win, with the longest road worth two and no largest army`() {
val road = table().build(a, BuildingType.CITY, 0).path(0, *ring.toIntArray())
@@ -520,6 +520,113 @@ class CatanRulesTest {
assertEquals(listOf<CatanMove>(DeclineTrade), CatanRules.legalMoves(offered, 1))
}
private val twoLumberForBrick =
OfferTrade(to = null, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK))
private fun openTable() = craft().copy(
resources = hands(
hand(Resource.LUMBER to 3),
hand(Resource.BRICK to 2),
hand(Resource.BRICK to 1, Resource.WOOL to 1),
),
)
@Test
fun `an open offer asks each other seat in turn until one accepts`() {
val state = openTable()
assertTrue(CatanRules.isLegalMove(state, 0, twoLumberForBrick))
val asked1 = CatanRules.applyMove(state, 0, twoLumberForBrick)
assertEquals(CatanPhase.TRADE_PENDING, asked1.phase)
assertEquals(1, CatanRules.currentSeat(asked1))
val asked2 = CatanRules.applyMove(asked1, 1, DeclineTrade)
assertEquals("a decline passes the offer on", 2, CatanRules.currentSeat(asked2))
assertEquals(CatanPhase.TRADE_PENDING, asked2.phase)
val done = CatanRules.applyMove(asked2, 2, AcceptTrade)
assertEquals(CatanPhase.PLAYING, done.phase)
assertNull(done.pendingTrade)
assertEquals(hand(Resource.LUMBER to 1, Resource.BRICK to 1), done.resources[0])
assertEquals(hand(Resource.LUMBER to 2, Resource.WOOL to 1), done.resources[2])
assertEquals("seat 1 never took part", state.resources[1], done.resources[1])
}
@Test
fun `an open offer nobody wants lapses back to the offerer`() {
var table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
table = CatanRules.applyMove(table, 1, DeclineTrade)
table = CatanRules.applyMove(table, 2, DeclineTrade)
assertEquals(CatanPhase.PLAYING, table.phase)
assertNull(table.pendingTrade)
assertEquals(0, CatanRules.currentSeat(table))
}
@Test
fun `seats with no cards are not asked`() {
val state = openTable().copy(resources = hands(hand(Resource.LUMBER to 3), hand(), hand(Resource.BRICK to 1)))
val table = CatanRules.applyMove(state, 0, twoLumberForBrick)
assertEquals(2, CatanRules.currentSeat(table))
}
@Test
fun `a counter is kept and the offerer chooses once everyone has answered`() {
var table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
// Seat 1 will not give a brick for two lumber but would for three.
val counter = CounterTrade(give = listOf(Resource.BRICK), get = listOf(Resource.LUMBER, Resource.LUMBER, Resource.LUMBER))
assertTrue(CatanRules.isLegalMove(table, 1, counter))
table = CatanRules.applyMove(table, 1, counter)
assertEquals(2, CatanRules.currentSeat(table))
table = CatanRules.applyMove(table, 2, DeclineTrade)
assertEquals(CatanPhase.TRADE_COUNTERS, table.phase)
assertEquals("the offerer chooses", 0, CatanRules.currentSeat(table))
assertEquals(
listOf<CatanMove>(AcceptCounter(1), DismissCounters),
CatanRules.legalMoves(table, 0),
)
val taken = CatanRules.applyMove(table, 0, AcceptCounter(1))
assertEquals(CatanPhase.PLAYING, taken.phase)
assertNull(taken.pendingTrade)
assertEquals(hand(Resource.BRICK to 1), taken.resources[0])
assertEquals(hand(Resource.LUMBER to 3, Resource.BRICK to 1), taken.resources[1])
}
@Test
fun `counters can all be turned down`() {
var table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
table = CatanRules.applyMove(
table, 1,
CounterTrade(listOf(Resource.BRICK), listOf(Resource.LUMBER, Resource.LUMBER, Resource.LUMBER)),
)
table = CatanRules.applyMove(table, 2, DeclineTrade)
val after = CatanRules.applyMove(table, 0, DismissCounters)
assertEquals(CatanPhase.PLAYING, after.phase)
assertNull(after.pendingTrade)
assertEquals(openTable().resources, after.resources)
}
@Test
fun `a counter must differ from the offer and be affordable`() {
val table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
assertFalse(
"the offer as made is an accept, not a counter",
CatanRules.isLegalMove(table, 1, CounterTrade(listOf(Resource.BRICK), listOf(Resource.LUMBER, Resource.LUMBER))),
)
assertFalse(
"cannot promise cards not held",
CatanRules.isLegalMove(table, 1, CounterTrade(listOf(Resource.ORE), listOf(Resource.LUMBER))),
)
assertFalse(
"cannot ask for cards the offerer lacks",
CatanRules.isLegalMove(table, 1, CounterTrade(listOf(Resource.BRICK), listOf(Resource.ORE))),
)
assertFalse(
"only the seat being asked may counter",
CatanRules.isLegalMove(table, 2, CounterTrade(listOf(Resource.WOOL), listOf(Resource.LUMBER))),
)
}
@Test
fun `the bank trades four for one and harbours do better`() {
val base = craft()
@@ -912,6 +1019,10 @@ class CatanRulesTest {
OfferTrade(1, listOf(Resource.LUMBER, Resource.LUMBER), listOf(Resource.ORE)),
AcceptTrade,
DeclineTrade,
OfferTrade(null, listOf(Resource.LUMBER), listOf(Resource.ORE)),
CounterTrade(listOf(Resource.WOOL), listOf(Resource.GRAIN, Resource.GRAIN)),
AcceptCounter(2),
DismissCounters,
MaritimeTrade(Resource.WOOL, Resource.GRAIN),
EndTurn,
EndFreeRoads,
@@ -6,7 +6,10 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.AcceptCounter
import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.CounterGoods
import org.prolibertate.games.game.catan.DismissCounters
import org.prolibertate.games.game.catan.ActivateKnight
import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad
@@ -164,7 +167,8 @@ class CatanCkInteractionTest {
// Structural: the discard and give dialogs build these and ask the engine.
is DiscardGoods, is GiveGoods -> "goodsDialog"
is OfferGoods -> "tradeDialog"
is OfferGoods, is CounterGoods -> "tradeDialog"
is AcceptCounter, DismissCounters -> "counterDialog"
else -> null
}