Katan: open trade offers and counter-offers
CI / Build and check (pull_request) Successful in 7m28s
CI / Deploy to Cloudflare Pages (pull_request) Skipped

- Offers can go to the whole table (asked in seat order, empty hands skipped) or one seat.
- The asked seat may accept (trade happens at once), decline, or counter with new terms.
- If nobody accepts and there are counters, the offerer takes one or turns them all down (new TRADE_COUNTERS phase).
- AI: fixes an inverted accept test (it weighed what it gives against what it gets backwards), picks the best counter.
- UI: Everyone option in the offer dialog, Counter… in the reply dialog, a counter-choice dialog.
- Tests and docs updated.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018njn2eMPAYqAoE49hGRg2X
This commit is contained in:
Claude
2026-10-15 20:24:45 +00:00
parent 12fec14034
commit d7d3c8a3ed
11 changed files with 532 additions and 61 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)))
}
}
@@ -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,