Merge origin/dev into feature/cities-and-knights
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user