Katan: open trade offers and counter-offers
- 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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user