Skip to content

Commit 6a73f21

Browse files
committed
Add support for "Tackle" skill
- Add UI controls for handling automatic use of Tackle on blocks and during dodge - Added Action Wheel and other UI controls for selecting to use Tackle or Not - Improved Game status messages around Tackle
1 parent 72ecfb6 commit 6a73f21

File tree

11 files changed

+264
-22
lines changed

11 files changed

+264
-22
lines changed

docs/bb2025/todo-skills-bb2025.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ a test class in `modules/jervis-engine/src/commonTest/kotlin/dk/ilios/jervis/bb2
3737
- [ ] Jump Up
3838
- [ ] Leap
3939
- [ ] Safe Pair of Hands
40+
- [ ] Does not work against Strip Ball (NAF)
4041
- [ ] Sidestep
4142
- [ ] Sprint
4243
- [x] Sure Feet
@@ -100,8 +101,13 @@ a test class in `modules/jervis-engine/src/commonTest/kotlin/dk/ilios/jervis/bb2
100101
- [ ] Pro
101102
- [ ] Steady Footing
102103
- [ ] Strip Ball
104+
- [ ] Does not work against Stand Firm (Fumbbl)
105+
- [ ] No score, if ball is stripped when pushed into end zone
103106
- [ ] Sure Hands
104-
- [ ] Tackle
107+
- [x] Tackle
108+
- [x] Prevent use of Dodge skill when dodging away
109+
- [x] Only use Tackle if other player has Dodge
110+
- [x] Defender does not count as having dodge during a Block
105111
- [ ] Taunt
106112
- [ ] Wrestle
107113

@@ -204,6 +210,7 @@ a test class in `modules/jervis-engine/src/commonTest/kotlin/dk/ilios/jervis/bb2
204210
- [ ] Mighty Blow
205211
- [ ] Multiple Block
206212
- [ ] During Multiple Block: Scoring Turnovers will win over End-Turn turnovers.
213+
- [ ] Tackle on both blocks
207214
- [ ] Stand Firm
208215
- [ ] Strong Arm
209216
- [ ] Thick Skull
@@ -217,8 +224,8 @@ a test class in `modules/jervis-engine/src/commonTest/kotlin/dk/ilios/jervis/bb2
217224
- [ ] Bombardier
218225
- [ ] Accurate skill works
219226
- [ ] Cannoneer skill works
220-
- [ ] Bone Armor*
221-
- [ ] Bone Fury*
227+
- [ ] Safe Pass skills works
228+
- [ ] Hail Mary Pass works
222229
- [ ] Bone Head*
223230
- [ ] Blood Lust (X+)*
224231
- [ ] Breathe Fire

modules/jervis-engine/src/commonMain/kotlin/com/jervisffb/engine/model/context/DodgeRollContext.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ data class DodgeRollContext(
1616
val targetSquare: FieldCoordinate,
1717
val roll: D6DieRoll? = null,
1818
val rollModifiers: List<DiceModifier> = emptyList(),
19+
val useTackle: Player? = null,
1920
val isSuccess: Boolean = true,
2021
): ProcedureContext {
2122
fun copyAndAddModifier(twoHeads: DiceModifier): DodgeRollContext {

modules/jervis-engine/src/commonMain/kotlin/com/jervisffb/engine/rules/bb2025/skills/Dodge.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import com.jervisffb.engine.model.Player
55
import com.jervisffb.engine.model.RerollSourceId
66
import com.jervisffb.engine.model.SkillId
77
import com.jervisffb.engine.model.SkillKeyword
8+
import com.jervisffb.engine.model.context.DodgeRollContext
9+
import com.jervisffb.engine.model.context.getContextOrNull
810
import com.jervisffb.engine.rules.DiceRollType
911
import com.jervisffb.engine.rules.common.procedures.DieRoll
1012
import com.jervisffb.engine.rules.common.skills.D6StandardSkillReroll
@@ -41,6 +43,7 @@ class Dodge(
4143
)
4244

4345
override fun canReroll(state: Game, type: DiceRollType, value: List<DieRoll<*>>, wasSuccess: Boolean?): Boolean {
44-
return type == DiceRollType.DODGE
46+
val context = state.getContextOrNull<DodgeRollContext>()
47+
return (type == DiceRollType.DODGE) && (context?.useTackle == null)
4548
}
4649
}

modules/jervis-engine/src/commonMain/kotlin/com/jervisffb/engine/rules/common/procedures/actions/block/Stumble.kt

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.jervisffb.engine.actions.GameAction
1010
import com.jervisffb.engine.actions.GameActionDescriptor
1111
import com.jervisffb.engine.commands.Command
1212
import com.jervisffb.engine.commands.SetPlayerState
13+
import com.jervisffb.engine.commands.buildCompositeCommand
1314
import com.jervisffb.engine.commands.compositeCommandOf
1415
import com.jervisffb.engine.commands.context.RemoveContext
1516
import com.jervisffb.engine.commands.context.SetContext
@@ -26,6 +27,7 @@ import com.jervisffb.engine.model.context.StumbleContext
2627
import com.jervisffb.engine.model.context.assertContext
2728
import com.jervisffb.engine.model.context.getContext
2829
import com.jervisffb.engine.model.hasSkill
30+
import com.jervisffb.engine.reports.ReportSkillUsed
2931
import com.jervisffb.engine.reports.ReportStumbleResult
3032
import com.jervisffb.engine.rules.Rules
3133
import com.jervisffb.engine.rules.common.procedures.tables.injury.KnockedDown
@@ -35,7 +37,9 @@ import com.jervisffb.engine.utils.INVALID_ACTION
3537

3638
/**
3739
* Resolve a Stumble when selected on a block die.
38-
* See page 57 in the rulebook.
40+
*
41+
* See page 57 in the BB2020 rulebook.
42+
* See page 62 in the BB2025 rulebook.
3943
*/
4044
object Stumble: Procedure() {
4145
override val initialNode: Node = ChooseToUseTackle
@@ -62,24 +66,26 @@ object Stumble: Procedure() {
6266
override fun actionOwner(state: Game, rules: Rules): Team = state.getContext<StumbleContext>().attacker.team
6367
override fun getAvailableActions(state: Game, rules: Rules): List<GameActionDescriptor> {
6468
val stumbleContext = state.getContext<StumbleContext>()
65-
return if (stumbleContext.attacker.hasSkill(SkillType.TACKLE)) {
69+
val attackerHasTackle = stumbleContext.attacker.hasSkill(SkillType.TACKLE)
70+
val defenderHasDodge = stumbleContext.defender.hasSkill(SkillType.DODGE)
71+
return if (attackerHasTackle && defenderHasDodge) {
6672
listOf(ConfirmWhenReady, CancelWhenReady)
6773
} else {
6874
listOf(ContinueWhenReady)
6975
}
7076
}
7177
override fun applyAction(action: GameAction, state: Game, rules: Rules): Command {
72-
val useTackle = when (action) {
73-
Confirm -> true
74-
Cancel,
75-
Continue -> false
76-
else -> INVALID_ACTION(action)
77-
}
78+
val useTackle = (action == Confirm)
7879
val updatedContext = state.getContext<StumbleContext>().copy(attackerUsesTackle = useTackle)
79-
return compositeCommandOf(
80-
SetContext(updatedContext),
81-
if (useTackle) GotoNode(ResolvePush) else GotoNode(ChooseToUseDodge)
82-
)
80+
return buildCompositeCommand {
81+
add(SetContext(updatedContext))
82+
if (useTackle) {
83+
add(ReportSkillUsed(updatedContext.attacker, SkillType.TACKLE))
84+
add(GotoNode(ResolvePush))
85+
} else {
86+
add(GotoNode(ChooseToUseDodge))
87+
}
88+
}
8389
}
8490
}
8591

modules/jervis-engine/src/commonMain/kotlin/com/jervisffb/engine/rules/common/procedures/actions/move/DodgeRoll.kt

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,16 @@ import com.jervisffb.engine.utils.calculateAvailableRerollsFor
6868
* a. -1 for each marking player in the target field.
6969
* b. Stunty* (Ignore all -1 marked modifiers in the target field).
7070
* c. Titchy* (+1).
71-
* 2. Choose optional modifiers. These apply to both roll and reroll.
71+
* 3. Choose optional modifiers. These apply to both roll and reroll.
7272
* a. Two Heads (+1).
7373
* b. Break Tackle (+1/+2 in 2020, +1/+2/+3 in 2025).
7474
* c. Prehensile Tail (-1).
7575
* d. Diving Tackle (-2, and user prone).
76-
* 4. Choose to Reroll or not.
77-
* 5. If Reroll. Choose optional modifiers with negative consequences for the user.
76+
* 4. Choose to use Tackle or not.
77+
* 5. Choose to Reroll or not.
78+
* 6. If Reroll. Choose optional modifiers with negative consequences for the user.
7879
* a. Diving Tackle
79-
* 6. Calculate the final result.
80+
* 7. Calculate the final result.
8081
*
8182
* Designer's Commentary (BB2020):
8283
* It is possible to wait using Diving Tackle until the reroll has been made.
@@ -330,11 +331,58 @@ object DodgeRoll: Procedure() {
330331
val success = testAgainstAgility(context.player, context.roll!!.result, context.rollModifiers)
331332
return compositeCommandOf(
332333
SetContext(context.copy(isSuccess = success)),
333-
if (afterReroll) ExitProcedure() else GotoNode(ChooseReRollSource)
334+
if (afterReroll) ExitProcedure() else GotoNode(ChooseToUseTackle)
334335
)
335336
}
336337
}
337338

339+
object ChooseToUseTackle: ActionNode() {
340+
override fun actionOwner(state: Game, rules: Rules): Team {
341+
return state.getContext<DodgeRollContext>().player.team.otherTeam()
342+
}
343+
344+
override fun getAvailableActions(state: Game, rules: Rules): List<GameActionDescriptor> {
345+
val context = state.getContext<DodgeRollContext>()
346+
val dodgingPlayerHasDodge = context.player.isSkillAvailable(SkillType.DODGE)
347+
val playersWithTackle = context.startingSquare.getSurroundingCoordinates(rules, distance = 1, includeOutOfBounds = false).mapNotNull {
348+
val player = state.field[it].player
349+
if (player != null && player.isSkillAvailable(SkillType.TACKLE)) {
350+
player
351+
} else {
352+
null
353+
}
354+
}
355+
356+
return if (dodgingPlayerHasDodge && playersWithTackle.isNotEmpty()) {
357+
listOf(SelectPlayer.fromPlayers(playersWithTackle), CancelWhenReady)
358+
} else {
359+
listOf(ContinueWhenReady)
360+
}
361+
}
362+
363+
override fun applyAction(
364+
action: GameAction,
365+
state: Game,
366+
rules: Rules
367+
): Command {
368+
val context = state.getContext<DodgeRollContext>()
369+
return when (action) {
370+
is PlayerSelected -> {
371+
val tacklePlayer = action.getPlayer(state)
372+
compositeCommandOf(
373+
ReportSkillUsed(tacklePlayer, SkillType.TACKLE),
374+
SetContext(context.copy(useTackle = tacklePlayer)),
375+
GotoNode(ChooseReRollSource)
376+
)
377+
}
378+
Cancel, Continue -> {
379+
GotoNode(ChooseReRollSource)
380+
}
381+
else -> INVALID_ACTION(action)
382+
}
383+
}
384+
}
385+
338386
/**
339387
* Choose where a reroll should come from (if any). This can be skills, team rerolls, special cards
340388
* or other sources.
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package com.jervisffb.test.bb2025.skills
2+
3+
import com.jervisffb.engine.actions.BlockTypeSelected
4+
import com.jervisffb.engine.actions.Cancel
5+
import com.jervisffb.engine.actions.Confirm
6+
import com.jervisffb.engine.actions.DirectionSelected
7+
import com.jervisffb.engine.actions.EndAction
8+
import com.jervisffb.engine.actions.NoRerollSelected
9+
import com.jervisffb.engine.actions.PlayerActionSelected
10+
import com.jervisffb.engine.actions.PlayerSelected
11+
import com.jervisffb.engine.actions.SelectRerollOption
12+
import com.jervisffb.engine.ext.d6
13+
import com.jervisffb.engine.ext.dblock
14+
import com.jervisffb.engine.ext.playerId
15+
import com.jervisffb.engine.model.Direction
16+
import com.jervisffb.engine.model.PlayerState
17+
import com.jervisffb.engine.model.locations.FieldCoordinate
18+
import com.jervisffb.engine.rules.bb2025.skills.Dodge
19+
import com.jervisffb.engine.rules.bb2025.skills.Tackle
20+
import com.jervisffb.engine.rules.common.actions.BlockType
21+
import com.jervisffb.engine.rules.common.actions.PlayerStandardActionType
22+
import com.jervisffb.engine.rules.common.skills.RegularTeamReroll
23+
import com.jervisffb.engine.rules.common.skills.SkillType
24+
import com.jervisffb.test.JervisGameBB2025Test
25+
import com.jervisffb.test.activatePlayer
26+
import com.jervisffb.test.ext.rollForward
27+
import com.jervisffb.test.moveTo
28+
import com.jervisffb.test.utils.SelectSingleBlockDieResult
29+
import com.jervisffb.test.utils.SelectTeamReroll
30+
import kotlin.test.BeforeTest
31+
import kotlin.test.Test
32+
import kotlin.test.assertEquals
33+
import kotlin.test.assertFalse
34+
import kotlin.test.assertTrue
35+
36+
/**
37+
* Class testing usage of the [Tackle] skill.
38+
*/
39+
class TackleTests: JervisGameBB2025Test() {
40+
41+
@BeforeTest
42+
override fun setUp() {
43+
super.setUp()
44+
startDefaultGame()
45+
}
46+
47+
@Test
48+
fun useTackleOnDodgingAway() {
49+
homeTeam["H1".playerId].addSkill(SkillType.TACKLE)
50+
awayTeam["A1".playerId].addSkill(SkillType.DODGE)
51+
controller.rollForward(
52+
*activatePlayer("A1", PlayerStandardActionType.MOVE),
53+
*moveTo(14, 5),
54+
1.d6, // Fail Dodge
55+
PlayerSelected("H1".playerId), // Use Tackle to prevent Dodge reroll
56+
)
57+
assertTrue(controller.getAvailableActions().get<SelectRerollOption>().options.none { it.getRerollSource(state) is Dodge })
58+
controller.rollForward(
59+
SelectTeamReroll<RegularTeamReroll>(),
60+
6.d6,
61+
EndAction,
62+
)
63+
assertEquals(PlayerState.STANDING, awayTeam["A1".playerId].state)
64+
}
65+
66+
@Test
67+
fun doNotUseTackleIfNotNeededOnDodge() {
68+
homeTeam["H1".playerId].addSkill(SkillType.TACKLE)
69+
controller.rollForward(
70+
*activatePlayer("A1", PlayerStandardActionType.MOVE),
71+
*moveTo(14, 5),
72+
1.d6, // Fail Dodge
73+
)
74+
// Dodge reroll is not available
75+
assertFalse(controller.getAvailableActions().get<SelectRerollOption>().options.any { it.getRerollSource(state) is Dodge })
76+
controller.rollForward(
77+
SelectTeamReroll<RegularTeamReroll>(),
78+
6.d6,
79+
EndAction,
80+
)
81+
assertEquals(PlayerState.STANDING, awayTeam["A1".playerId].state)
82+
}
83+
84+
@Test
85+
fun useTackleOnBlock() {
86+
homeTeam["H1".playerId].addSkill(SkillType.DODGE)
87+
awayTeam["A1".playerId].addSkill(SkillType.TACKLE)
88+
val attacker = state.getPlayerById("A1".playerId)
89+
val defender = state.getPlayerById("H1".playerId)
90+
controller.rollForward(
91+
PlayerSelected(attacker.id),
92+
PlayerActionSelected(PlayerStandardActionType.BLOCK),
93+
PlayerSelected(defender.id),
94+
BlockTypeSelected(BlockType.STANDARD),
95+
5.dblock, // Stumble result
96+
NoRerollSelected(),
97+
SelectSingleBlockDieResult(),
98+
Confirm, // Use Tackle
99+
DirectionSelected(Direction.UP_LEFT),
100+
Cancel // Do not follow up
101+
)
102+
assertEquals(FieldCoordinate(13, 5), attacker.location)
103+
assertEquals(PlayerState.STANDING, attacker.state)
104+
assertEquals(FieldCoordinate(11, 4), defender.location)
105+
assertEquals(PlayerState.KNOCKED_DOWN, defender.state)
106+
}
107+
108+
@Test
109+
fun doNotUseTackleIfNotNeededOnBlock() {
110+
awayTeam["A1".playerId].addSkill(SkillType.TACKLE)
111+
val attacker = state.getPlayerById("A1".playerId)
112+
val defender = state.getPlayerById("H1".playerId)
113+
controller.rollForward(
114+
PlayerSelected(attacker.id),
115+
PlayerActionSelected(PlayerStandardActionType.BLOCK),
116+
PlayerSelected(defender.id),
117+
BlockTypeSelected(BlockType.STANDARD),
118+
5.dblock, // Stumble result
119+
NoRerollSelected(),
120+
SelectSingleBlockDieResult(),
121+
DirectionSelected(Direction.UP_LEFT),
122+
Cancel // Do not follow up
123+
)
124+
assertEquals(FieldCoordinate(13, 5), attacker.location)
125+
assertEquals(PlayerState.STANDING, attacker.state)
126+
assertEquals(FieldCoordinate(11, 4), defender.location)
127+
assertEquals(PlayerState.KNOCKED_DOWN, defender.state)
128+
}
129+
}

modules/jervis-ui/src/commonMain/kotlin/com/jervisffb/ui/game/state/ManualActionProvider.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ import com.jervisffb.engine.rules.common.procedures.TheKickOff
5454
import com.jervisffb.engine.rules.common.procedures.actions.blitz.BlitzAction
5555
import com.jervisffb.engine.rules.common.procedures.actions.block.BlockAction
5656
import com.jervisffb.engine.rules.common.procedures.actions.block.PushStepInitialMoveSequence
57+
import com.jervisffb.engine.rules.common.procedures.actions.block.Stumble
5758
import com.jervisffb.engine.rules.common.procedures.actions.block.standard.StandardBlockChooseResult
59+
import com.jervisffb.engine.rules.common.procedures.actions.move.DodgeRoll
5860
import com.jervisffb.engine.rules.common.procedures.actions.move.JumpStep
5961
import com.jervisffb.engine.rules.common.procedures.actions.pass.PassContext
6062
import com.jervisffb.engine.rules.common.procedures.tables.injury.ArmourRoll
@@ -574,6 +576,16 @@ open class ManualActionProvider(
574576
}
575577
}
576578

579+
if (menuViewModel.isFeatureEnabled(Feature.ALWAYS_USE_TACKLE_ON_DODGE) && (currentNode == DodgeRoll.ChooseToUseTackle)) {
580+
// Always select the first available player to use Tackle
581+
val selectedPlayer = availableActions.get<SelectPlayer>().players.first()
582+
return PlayerSelected(selectedPlayer)
583+
}
584+
585+
if (menuViewModel.isFeatureEnabled(Feature.ALWAYS_USE_TACKLE_ON_STUMBLE) && (currentNode == Stumble.ChooseToUseTackle)) {
586+
return Confirm
587+
}
588+
577589
return null
578590
}
579591

modules/jervis-ui/src/commonMain/kotlin/com/jervisffb/ui/game/state/decorators/CancelDecorator.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.jervisffb.engine.model.Game
77
import com.jervisffb.engine.model.Team
88
import com.jervisffb.engine.rules.bb2025.procedures.actions.pass.InterceptionStep
99
import com.jervisffb.engine.rules.bb2025.procedures.skills.UseShadowingStep
10+
import com.jervisffb.engine.rules.common.procedures.actions.move.DodgeRoll
1011
import com.jervisffb.ui.game.UiSnapshotAccumulator
1112
import com.jervisffb.ui.game.state.ManualActionProvider
1213

@@ -17,7 +18,9 @@ object CancelDecorator : FieldActionDecorator<CancelWhenReady> {
1718

1819
private val nodesToDecorate = setOf(
1920
UseShadowingStep.CheckIfShadowingIsAvailable,
20-
InterceptionStep.SelectPlayerForInterception
21+
InterceptionStep.SelectPlayerForInterception,
22+
DodgeRoll.ChooseToUseTackle,
23+
DodgeRoll.ChooseToUsePrehensileTail
2124
)
2225

2326
override fun isApplicable(state: Game, request: ActionRequest): Boolean {
@@ -35,6 +38,8 @@ object CancelDecorator : FieldActionDecorator<CancelWhenReady> {
3538
val title = when (state.stack.currentNode()) {
3639
UseShadowingStep.CheckIfShadowingIsAvailable -> "Do not use Shadowing"
3740
InterceptionStep.SelectPlayerForInterception -> "Do not intercept"
41+
DodgeRoll.ChooseToUseTackle -> "Do not use Tackle"
42+
DodgeRoll.ChooseToUsePrehensileTail -> "Do not use Prehensile Tail"
3843
else -> error("Unsupported node: ${state.stack.currentNode()}")
3944
}
4045
acc.updateGameStatus {

0 commit comments

Comments
 (0)