diff --git a/docs/features.md b/docs/features.md index 6b172b7..e83f6af 100644 --- a/docs/features.md +++ b/docs/features.md @@ -5,6 +5,9 @@ List of features intended for the project. - [X] Single battle - [X] Battle confirmation upgrade - [X] Better player names +- [X] New augment engine and coach personas +- [X] Adding rules +- [ ] Ability to create more coaches and teams - [ ] Better coach creation journey - [ ] Events emitted for lane battles - [ ] Settings to clear game data diff --git a/docs/proposal.md b/docs/proposal.md deleted file mode 100644 index 07df7ca..0000000 --- a/docs/proposal.md +++ /dev/null @@ -1,131 +0,0 @@ -### Proposal: Token-Only Tactical Playtest (Rules-Ready Draft) - -This revision removes `Attack/Defense` and `Rush Lane` mechanics for now. -The playtest focus is strictly on high-impact token decisions and coach identity through token access. - ---- - -### 1. Playtest Scope and Core Rules - -#### Scope -* This phase tests only token-driven decision making. -* No `ATK/DEF` stats. -* No `Rush Lane` declarations. - -#### Duel Flow (Token Windows) -1. **Pre-roll Window**: Coaches may declare one Pre-roll token. -2. **Roll**: Both players roll simultaneously. -3. **Reveal**: Results are revealed. -4. **Reaction Window**: Eligible Reaction tokens may be declared. -5. **Resolution Window**: Eligible Resolution tokens may be declared. -6. **Lane Update**: Apply a final outcome for the duel. - -#### Duel Definition and Token Scope -* A **duel** is one complete lane interaction between the two opposing players, from Pre-roll Window through Lane Update. -* A lane may contain multiple duels across a match (for example, if a lane remains unresolved and is contested again later). -* Unless a token explicitly says otherwise, a token affects **only the current duel** in that lane. -* Token effects apply to all rolls that occur inside that duel (for example, replacement re-rolls), but never carry into future duels or other lanes. - -#### Global Token Constraints -* Each coach equips exactly **3 tokens** per match. -* Each equipped token is **single-use**. -* Max **1 token per coach per duel**. -* Both coaches may use a token in the same duel. -* Tokens do not stack for the same coach in a single duel. - ---- - -### 2. Token Library (Expanded for User Testing) - -#### Twist of Fate (Advantage) -* **Timing**: Pre-roll Window. -* **Effect**: Roll `2d6` and keep the highest. -* **Intent**: Front-load pressure in must-win lanes. - -#### Second Chance (Reactive Re-roll) -* **Timing**: Reaction Window, only if your first roll did not win. -* **Effect**: Re-roll your own die once; second result replaces the first. -* **Intent**: Stabilize critical moments after a miss or tie. - -#### Power Play (Flat Boost) -* **Timing**: Pre-roll Window. -* **Effect**: Gain `+1` to your roll total this duel. -* **Intent**: Reliable low-variance push. - -#### Brace (Damage Control) -* **Timing**: Pre-roll Window. -* **Effect**: Opponent gets `-1` to their roll total this duel. -* **Intent**: Defensive denial and tempo slowdown. - -#### Precision Strike (Near-Miss Conversion) -* **Timing**: Reaction Window, only if you lost by exactly `1`. -* **Effect**: Add `+1` to your revealed total. -* **Intent**: Skill-expression token for close reads. - -#### Jamming Signal (Pre-roll Counter) -* **Timing**: Pre-roll Window. -* **Effect**: Cancel the opponent's declared Pre-roll token. -* **Intent**: Anti-pattern counterplay. -* **Constraint**: Cannot cancel Reaction or Resolution tokens. - -#### Last Stand (Resolution Save) -* **Timing**: Resolution Window, only if you would lose the duel. -* **Effect**: Prevent your elimination this duel; lane remains unresolved. -* **Intent**: Comeback insurance for high-value lanes. - -#### Momentum Surge (Win Streak Convert) -* **Timing**: Pre-roll Window, only if you won your previous duel. -* **Effect**: Gain `+2` this duel. -* **Intent**: Snowball option with explicit condition gate. - -#### Ice in Veins (Tie Breaker) -* **Timing**: Resolution Window, only on a tie. -* **Effect**: Convert tie into a win for your side. -* **Intent**: Tie-state control and clutch finish potential. - -#### Smoke Screen (Information Denial) -* **Timing**: Pre-roll Window. -* **Effect**: Your token declaration remains hidden until after reveal. -* **Intent**: Mind-game tool to punish reactive opponents. - ---- - -### 3. Coach Personas (Token Access Limits) - -At match start, the player selects one coach persona. -Each persona restricts which tokens can be equipped for that match. - -#### Coach Loadout Rule -* Equip exactly **3 single-use tokens** from your persona's allowed list. -* No duplicate token names in the same loadout. - -#### Persona A: Vanguard Coach (Aggressive) -* **Allowed Tokens**: `Twist of Fate`, `Power Play`, `Momentum Surge`, `Precision Strike`, `Ice in Veins` -* **Playstyle**: Tempo-first and lane conversion pressure. -* **Restriction Theme**: No hard defensive save tokens. - -#### Persona B: Bastion Coach (Defensive) -* **Allowed Tokens**: `Second Chance`, `Brace`, `Last Stand`, `Jamming Signal`, `Ice in Veins` -* **Playstyle**: Attrition, denial, and mistake recovery. -* **Restriction Theme**: No high-spike momentum token. - -#### Persona C: Trickster Coach (Control) -* **Allowed Tokens**: `Jamming Signal`, `Smoke Screen`, `Precision Strike`, `Second Chance`, `Power Play` -* **Playstyle**: Information warfare and timing traps. -* **Restriction Theme**: No direct resolution-save token. - -#### Persona D: Wildcard Coach (Flexible) -* **Allowed Tokens**: Any token except `Momentum Surge` and `Last Stand`. -* **Playstyle**: Broad adaptability with reduced extreme effects. -* **Restriction Theme**: No strongest snowball/safety endpoints. - ---- - -### 4. Playtest Objectives - -Track these outcomes to evaluate whether token mechanics are fun and understandable: -* **Token usage rate** by token and by persona. -* **Round swing rate** (how often a token changes the duel winner). -* **Persona pick rate** and win rate spread. -* **Perceived fairness** (post-match user rating). -* **Clarity score** (players can explain what each used token did). diff --git a/go.mod b/go.mod index bb7095e..9b672a7 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,8 @@ go 1.26.3 require ( charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.7 - charm.land/lipgloss/v2 v2.0.4 + charm.land/glamour/v2 v2.0.1 + charm.land/lipgloss/v2 v2.0.5 github.com/code-gorilla-au/env v1.1.1 github.com/code-gorilla-au/odize v1.3.5 github.com/go-faker/faker/v4 v4.8.0 @@ -13,30 +14,39 @@ require ( ) require ( + github.com/alecthomas/chroma/v2 v2.27.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260713092251-4bee1914c0cf // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20260713092006-0d683c34c74b // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dlclark/regexp2/v2 v2.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect + github.com/yuin/goldmark v1.8.4 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 43f692f..11a082b 100644 --- a/go.sum +++ b/go.sum @@ -2,20 +2,32 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= -charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= -charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= +charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c= +charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k= +charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= +charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk= -github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= +github.com/charmbracelet/ultraviolet v0.0.0-20260713092251-4bee1914c0cf h1:ZzzZmTK4743XxEhoZbwFj2bh7WlI29USML/EVJBI2i0= +github.com/charmbracelet/ultraviolet v0.0.0-20260713092251-4bee1914c0cf/go.mod h1:psnCZIfwwxVs6v6DhUc6NJ8AQ3ejvs2ejKwoOMeVmUk= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/slice v0.0.0-20260713092006-0d683c34c74b h1:N0srud4Ch13fFAjgMlnKR2h41/2ISRbbzCJpTXGcNR0= +github.com/charmbracelet/x/exp/slice v0.0.0-20260713092006-0d683c34c74b/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= @@ -30,6 +42,8 @@ github.com/code-gorilla-au/env v1.1.1 h1:4rkSwCnyymKh+KGAOPx3fEg9v2ZV5i9r92bSf7x github.com/code-gorilla-au/env v1.1.1/go.mod h1:KE4Ymfz5MhMi7SX3ZKH4iMFAHsDCvwOV8WTzgpwzzE4= github.com/code-gorilla-au/odize v1.3.5 h1:Bjb0c1NXRkbEppsCs2PSN4DHWy3yWIggTXdroibWF54= github.com/code-gorilla-au/odize v1.3.5/go.mod h1:+PtShsIEca9bAfxltU00OVD75aR5NvtkpOW/HGHdi9w= +github.com/dlclark/regexp2/v2 v2.5.0 h1:liNiWIPCvCh5HBcYfsjd+P16AG79fwd6T1Toy2gOtEA= +github.com/dlclark/regexp2/v2 v2.5.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-faker/faker/v4 v4.8.0 h1:QAKmb4TyAMxIgf3a8fXubQXwFFJviBGL2/IDa34N6JQ= @@ -38,8 +52,12 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -50,6 +68,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -62,19 +82,25 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= +github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go index 6f49092..736e387 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/dice_roll.go @@ -92,36 +92,33 @@ func makeDecision(input DecisionInput) DuelResult { if input.teamA.roll == input.teamB.roll { return DuelResult{ - Player: 0, - Outcome: Draw, - Roll: 0, - RollDelta: 0, - TriggeredAugment: augments.NoAugment, - TeamA: teamA, - TeamB: teamB, + Player: 0, + Outcome: Draw, + Roll: 0, + RollDelta: 0, + TeamA: teamA, + TeamB: teamB, } } if input.teamA.roll > input.teamB.roll { return DuelResult{ - Player: input.teamA.player, - Outcome: TeamA, - Roll: input.teamA.roll, - RollDelta: input.teamA.roll - input.teamB.roll, - TriggeredAugment: input.teamA.triggeredAugment, - TeamA: teamA, - TeamB: teamB, + Player: input.teamA.player, + Outcome: TeamA, + Roll: input.teamA.roll, + RollDelta: input.teamA.roll - input.teamB.roll, + TeamA: teamA, + TeamB: teamB, } } return DuelResult{ - Player: input.teamB.player, - Outcome: TeamB, - Roll: input.teamB.roll, - RollDelta: input.teamB.roll - input.teamA.roll, - TriggeredAugment: input.teamB.triggeredAugment, - TeamA: teamA, - TeamB: teamB, + Player: input.teamB.player, + Outcome: TeamB, + Roll: input.teamB.roll, + RollDelta: input.teamB.roll - input.teamA.roll, + TeamA: teamA, + TeamB: teamB, } } diff --git a/internal/games/dice_roll_rules.go b/internal/games/dice_roll_rules.go index 877556a..5aa6440 100644 --- a/internal/games/dice_roll_rules.go +++ b/internal/games/dice_roll_rules.go @@ -114,9 +114,9 @@ func RuleBrace(input DecisionInput) DecisionInput { } rollDelta := math.Abs(float64(input.teamA.roll) - float64(input.teamB.roll)) - lostByOne := rollDelta <= 2 + withinBounds := rollDelta <= 2 - if !lostByOne { + if !withinBounds { return input } diff --git a/internal/games/rounds.go b/internal/games/rounds.go index 6cccd82..eaff6ee 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -28,7 +28,6 @@ func (r *Round) ResolveLanes(rollFn RollStrategy) RoundResult { } return r.calculateWinner(result) - } func (r *Round) calculateWinner(result []RoundResult) RoundResult { @@ -124,8 +123,8 @@ func (r *Round) executeDuels(lane int, rollFn RollStrategy) error { re = rollFn.Run(rollInput) re.Lane = lane - r.TeamB.Augments = popAugment(r.TeamB.Augments, re.TriggeredAugment) - r.TeamA.Augments = popAugment(r.TeamA.Augments, re.TriggeredAugment) + r.TeamB.Augments = popAugment(r.TeamB.Augments, re.TeamB.TriggeredAugment) + r.TeamA.Augments = popAugment(r.TeamA.Augments, re.TeamA.TriggeredAugment) } r.DuelResults = append(r.DuelResults, re) diff --git a/internal/games/types.go b/internal/games/types.go index c1cd32a..89477e9 100644 --- a/internal/games/types.go +++ b/internal/games/types.go @@ -50,14 +50,13 @@ type Round struct { } type DuelResult struct { - Player int64 `json:"player"` - Outcome Outcome `json:"outcome"` - Roll int `json:"roll"` - RollDelta int `json:"roll_delta"` - TriggeredAugment augments.Name `json:"triggered_augment"` - Lane int `json:"lane"` - TeamA PlayerRoll `json:"team_a"` - TeamB PlayerRoll `json:"team_b"` + Player int64 `json:"player"` + Outcome Outcome `json:"outcome"` + Roll int `json:"roll"` + RollDelta int `json:"roll_delta"` + Lane int `json:"lane"` + TeamA PlayerRoll `json:"team_a"` + TeamB PlayerRoll `json:"team_b"` } type PlayerRoll struct { @@ -67,16 +66,16 @@ type PlayerRoll struct { } type TeamStatistics struct { - GamesPlayed int `json:"games_played,omitempty"` - Wins int `json:"wins,omitempty"` - Draws int `json:"draws,omitempty"` - Losses int `json:"losses,omitempty"` - WinRate float64 `json:"win_rate,omitempty"` - RoundsWon int `json:"rounds_won,omitempty"` - RoundsLost int `json:"rounds_lost,omitempty"` - RoundDifferential int `json:"round_differential,omitempty"` - AverageRoundsWon float64 `json:"average_rounds_won,omitempty"` - AverageRoundsLost float64 `json:"average_rounds_lost,omitempty"` + GamesPlayed int `json:"games_played"` + Wins int `json:"wins"` + Draws int `json:"draws"` + Losses int `json:"losses"` + WinRate float64 `json:"win_rate"` + RoundsWon int `json:"rounds_won"` + RoundsLost int `json:"rounds_lost"` + RoundDifferential int `json:"round_differential"` + AverageRoundsWon float64 `json:"average_rounds_won"` + AverageRoundsLost float64 `json:"average_rounds_lost"` } type TeamConfig struct { @@ -88,9 +87,9 @@ type TeamConfig struct { } type TeamFormation struct { - TeamID int64 `json:"team_id,omitempty"` + TeamID int64 `json:"team_id"` Augments []augments.Effect `json:"augments"` - Lanes [3][]int64 `json:"lanes,omitempty"` + Lanes [3][]int64 `json:"lanes"` } type LanesConfig struct { diff --git a/internal/ui/app.go b/internal/ui/app.go index ab8d496..c241364 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -26,6 +26,7 @@ type RootModel struct { pageNewTournament tea.Model pageNewBattle tea.Model pageGame tea.Model + pageRules tea.Model globalState *uistate.GlobalState teamsSvc *teams.Service playbookSvc *playbooks.Service @@ -53,6 +54,7 @@ func New(deps Dependencies) *RootModel { pageNewTournament: NewModelNewTournament(state, theme), pageNewBattle: uibattle.NewBattleModel(state, deps.TeamsSvc, deps.PlaybookSvc, deps.GameSvc, theme), pageGame: iugame.NewGameModel(state, deps.TeamsSvc, deps.GameSvc, theme), + pageRules: NewModelRules(state, theme), globalState: state, teamsSvc: deps.TeamsSvc, playbookSvc: deps.PlaybookSvc, @@ -125,6 +127,8 @@ func (m *RootModel) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) cmds = append(cmds, cmd) m.pageGame, cmd = m.pageGame.Update(msg) cmds = append(cmds, cmd) + m.pageRules, cmd = m.pageRules.Update(msg) + cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } @@ -143,6 +147,8 @@ func (m *RootModel) updateCurrentPage(msg tea.Msg) (tea.Model, tea.Cmd) { m.pageNewBattle, cmd = m.pageNewBattle.Update(msg) case uistate.PageGame: m.pageGame, cmd = m.pageGame.Update(msg) + case uistate.PageRules: + m.pageRules, cmd = m.pageRules.Update(msg) } return m, cmd } @@ -165,6 +171,8 @@ func (m *RootModel) View() tea.View { return m.pageNewBattle.View() case uistate.PageGame: return m.pageGame.View() + case uistate.PageRules: + return m.pageRules.View() } return tea.NewView("unknown page") diff --git a/internal/ui/components/game.go b/internal/ui/components/game.go index 34f6f7b..c581e2f 100644 --- a/internal/ui/components/game.go +++ b/internal/ui/components/game.go @@ -19,11 +19,15 @@ type Game struct { result games.RoundResult rollEngine games.RollStrategy roundComp Round + countdown int } // MsgResolveRound is sent when the round should be resolved. type MsgResolveRound struct{} +// MsgTick is sent when the timer ticks. +type MsgTick struct{} + // MsgNextRound is sent when the user wants to proceed to the next round. type MsgNextRound struct{} @@ -60,7 +64,15 @@ func (g *Game) Init() tea.Cmd { func (g *Game) Update(msg tea.Msg) tea.Cmd { switch msg := msg.(type) { case MsgResolveRound: - g.handleRound() + return g.handleRound() + case MsgTick: + g.countdown-- + if g.countdown > 0 { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { return MsgTick{} }) + } + return func() tea.Msg { + return MsgNextRound{} + } case tea.KeyMsg: if g.resolved { switch msg.String() { @@ -74,9 +86,9 @@ func (g *Game) Update(msg tea.Msg) tea.Cmd { return nil } -func (g *Game) handleRound() { +func (g *Game) handleRound() tea.Cmd { if g.resolved { - return + return nil } res, err := g.game.ResolveRound(g.rollEngine) @@ -86,7 +98,11 @@ func (g *Game) handleRound() { // Update the round component with the final state currentRoundIdx := g.game.CurrentRound() - 1 g.roundComp = NewRound(g.game.Rounds()[currentRoundIdx], g.teamAName, g.teamBName) + + g.countdown = 3 + return tea.Tick(time.Second, func(t time.Time) tea.Msg { return MsgTick{} }) } + return nil } // View renders the Game component. @@ -116,7 +132,7 @@ func (g *Game) View(theme styles.IceTheme) string { winnerInfo := theme.Winner.Render(fmt.Sprintf("WINNER: %s! (%d players remaining)", winner, g.result.RemainingPlayers)) prompt := theme.Muted. MarginTop(1). - Render("Press Enter for next round...") + Render(fmt.Sprintf("Next round starting in %d, press Enter to start ...", g.countdown)) footer = lipgloss.JoinVertical(lipgloss.Center, winnerInfo, prompt) } else { diff --git a/internal/ui/components/game_test.go b/internal/ui/components/game_test.go index 31c754c..c79952e 100644 --- a/internal/ui/components/game_test.go +++ b/internal/ui/components/game_test.go @@ -69,14 +69,14 @@ func TestGameComponent(t *testing.T) { // Handle MsgResolveRound cmd := gComp.Update(MsgResolveRound{}) - odize.AssertNil(t, cmd) + odize.AssertTrue(t, cmd != nil) odize.AssertTrue(t, gComp.resolved) // Resolved state view = gComp.View(theme) odize.AssertTrue(t, strings.Contains(view, "Team A 1 - 0 Team B")) odize.AssertTrue(t, strings.Contains(view, "WINNER: Team A")) - odize.AssertTrue(t, strings.Contains(view, "Press Enter for next round...")) + odize.AssertTrue(t, strings.Contains(view, "Next round starting in 3, press Enter to start ...")) // Handle Enter key cmd = gComp.Update(tea.KeyPressMsg{Text: "enter"}) @@ -86,6 +86,37 @@ func TestGameComponent(t *testing.T) { odize.AssertTrue(t, ok) }) + group.Test("should trigger MsgNextRound after countdown", func(t *testing.T) { + svc := games.NewService(&mockStore{}) + game, err := svc.NewGame(context.Background(), games.NewGameParams{ + TeamA: teamA, + TeamB: teamB, + }) + odize.AssertNoError(t, err) + + gComp := NewGame(&game, "Team A", "Team B", &games.TestEngine{RollFn: rollFn}) + + // Handle MsgResolveRound + cmd := gComp.Update(MsgResolveRound{}) + odize.AssertTrue(t, cmd != nil) // Should return a tick cmd + odize.AssertEqual(t, 3, gComp.countdown) + + // Simulate ticks + for i := 0; i < 3; i++ { + cmd = gComp.Update(MsgTick{}) + if i < 2 { + odize.AssertTrue(t, cmd != nil) + odize.AssertEqual(t, 2-i, gComp.countdown) + } else { + // Last tick, countdown reaches 0, command should be MsgNextRound + odize.AssertTrue(t, cmd != nil) + msg := cmd() + _, ok := msg.(MsgNextRound) + odize.AssertTrue(t, ok) + } + } + }) + err := group.Run() odize.AssertNoError(t, err) } diff --git a/internal/ui/components/title_menu.go b/internal/ui/components/title_menu.go index 7db6ee8..a992887 100644 --- a/internal/ui/components/title_menu.go +++ b/internal/ui/components/title_menu.go @@ -11,6 +11,7 @@ const ( TitleItemLockerRoom TitleItemNewTournament TitleItemNewBattleSelection + TitleItemRules ) func (i TitleItem) String() string { @@ -23,6 +24,8 @@ func (i TitleItem) String() string { return "New Tournament" case TitleItemNewBattleSelection: return "New Battle" + case TitleItemRules: + return "Rules" } return "" } @@ -37,7 +40,7 @@ func NewTitleMenu(hasCoach bool) TitleMenu { if !hasCoach { items = []TitleItem{TitleItemCreateCoach} } else { - items = []TitleItem{TitleItemLockerRoom, TitleItemNewTournament, TitleItemNewBattleSelection} + items = []TitleItem{TitleItemLockerRoom, TitleItemNewBattleSelection, TitleItemRules} } return TitleMenu{ items: items, @@ -83,7 +86,7 @@ func (m *TitleMenu) SetHasCoach(hasCoach bool) { if !hasCoach { items = []TitleItem{TitleItemCreateCoach} } else { - items = []TitleItem{TitleItemLockerRoom, TitleItemNewTournament, TitleItemNewBattleSelection} + items = []TitleItem{TitleItemLockerRoom, TitleItemNewBattleSelection, TitleItemRules} } m.items = items if m.cursor >= len(m.items) { diff --git a/internal/ui/page_rules.go b/internal/ui/page_rules.go new file mode 100644 index 0000000..31fe55d --- /dev/null +++ b/internal/ui/page_rules.go @@ -0,0 +1,106 @@ +package ui + +import ( + _ "embed" + "fmt" + + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/glamour/v2" +) + +//go:embed rules.md +var rulesContent string + +type rulesKeyMap struct { + uistate.KeyMap +} + +func newRulesKeyMap() rulesKeyMap { + return rulesKeyMap{ + KeyMap: uistate.NewKeyMap(), + } +} + +type ModelRules struct { + width int + height int + theme styles.IceTheme + globalState *uistate.GlobalState + viewport viewport.Model + keys rulesKeyMap + ready bool +} + +func NewModelRules(globalState *uistate.GlobalState, theme styles.IceTheme) *ModelRules { + return &ModelRules{ + globalState: globalState, + theme: theme, + keys: newRulesKeyMap(), + } +} + +func (m *ModelRules) Init() tea.Cmd { + return nil +} + +func (m *ModelRules) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + if key.Matches(msg, m.keys.Back) { + return m, func() tea.Msg { + return uistate.MsgSwitchPage{NewPage: uistate.PageTitle} + } + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + if !m.ready { + m.viewport = viewport.New(viewport.WithWidth(m.width), viewport.WithHeight(m.height)) + m.ready = true + } else { + m.viewport = viewport.New(viewport.WithWidth(m.width), viewport.WithHeight(m.height)) + } + + } + + if m.ready { + m.viewport, cmd = m.viewport.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m *ModelRules) headerView() string { + title := m.theme.Title.Render("Rules") + return title +} + +func (m *ModelRules) footerView() string { + info := m.theme.Muted.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) + return info +} + +func (m *ModelRules) View() tea.View { + if !m.ready { + return tea.NewView("Initializing...") + } + + rendered, err := glamour.Render(rulesContent, "dark") + if err != nil { + return tea.NewView("Err: " + err.Error()) + } + + m.viewport.SetContent(rendered) + view := tea.NewView(m.viewport.View()) + view.AltScreen = true + return view +} diff --git a/internal/ui/page_rules_test.go b/internal/ui/page_rules_test.go new file mode 100644 index 0000000..49bf8be --- /dev/null +++ b/internal/ui/page_rules_test.go @@ -0,0 +1,39 @@ +package ui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" +) + +func TestModelRules(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("should route to title page when q is pressed", func(t *testing.T) { + theme := styles.NewIceTheme() + m := NewModelRules(&uistate.GlobalState{}, theme) + m.width = 100 + m.height = 50 + + // We need to trigger WindowSizeMsg first to initialize viewport + m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) + + _, cmd := m.Update(tea.KeyPressMsg{Text: "esc"}) + odize.AssertTrue(t, cmd != nil) + + msg := cmd() + switch switchMsg := msg.(type) { + case uistate.MsgSwitchPage: + odize.AssertEqual(t, uistate.PageTitle, switchMsg.NewPage) + default: + t.Fatalf("expected MsgSwitchPage, got %T", msg) + } + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index 0d56b3e..030b8c8 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -126,6 +126,10 @@ func (m *ModelTitle) handleMenuSelect() (tea.Model, tea.Cmd, bool) { return m, func() tea.Msg { return uistate.MsgSwitchPage{NewPage: uistate.PageNewBattle} }, true + case components.TitleItemRules: + return m, func() tea.Msg { + return uistate.MsgSwitchPage{NewPage: uistate.PageRules} + }, true } return nil, nil, false diff --git a/docs/rules.md b/internal/ui/rules.md similarity index 79% rename from docs/rules.md rename to internal/ui/rules.md index 7e5848d..bbcc6a1 100644 --- a/docs/rules.md +++ b/internal/ui/rules.md @@ -63,7 +63,7 @@ To keep the game simple but add meaningful decisions, playbooks and coaches shou ### 3) Tactical Tokens Coaches use tokens to influence the outcome of the match. -- Each token is **single-use** per match. +- Each token is **single-use** per round. - Max **3 augmentations per game**. - An augmentation is selected **before each round**. @@ -71,15 +71,18 @@ Coaches use tokens to influence the outcome of the match. | Token | Timing | Effect | | :--- | :--- | :--- | -| **Twist of Fate** | Pre-roll | Roll 2d6 and keep the highest. | -| **Overpower** | Pre-roll | Gain +1 to your roll total this duel. | -| **Hamstring** | Pre-roll | Opponent gets -1 to their roll total this duel. | -| **Jamming Signal** | Pre-roll | Cancel the opponent's declared Pre-roll token. | -| **Momentum Surge** | Pre-roll | If you won your previous duel, gain +2 this duel. | -| **Second Chance** | Reaction | Re-roll your own die once; second result replaces the first. | -| **Precision Strike** | Reaction | Add +1 to your revealed total (usually if losing by 1). | -| **Last Stand** | Resolution | Prevent your elimination; lane remains unresolved for this duel. | -| **Ice in Veins** | Resolution | Convert a tie into a win for your side. | +| **Twist of Fate** | After Roll | If last round was a loss, roll 2d6 keep highest. | +| **Overpower** | After Roll | Gain +1 to your roll total this duel. | +| **Precision Strike** | After Roll | If you roll a 4 or higher, gain +1 to your total. | +| **Momentum Surge** | After Roll | If last duel was a win, gain +1 this duel. | +| **Brace** | After Augments | Losing roll by 2 or less, convert result to a tie. | +| **Fortify** | After Roll | If last duel was a tie, gain +2 to roll | +| **Second Chance** | After Roll | Re-roll your own die once; second result replaces the first. | +| **Last Stand** | After Roll | If last duel was a loss, add +2 to roll | +| **Hamstring** | After Roll | Opponent gets -2 to their roll total this duel. | +| **Pocket Sand** | Before Roll | Cancel the opponent's declared Pre-roll augment. | +| **Poison Edge** | After Roll | If last duel was a loss, opponent has -2 to roll | +| **Ice in Veins** | After Augments | Convert tie into a win for your side. | ### 4) Coach Personas diff --git a/internal/ui/uistate/state.go b/internal/ui/uistate/state.go index 006fa2e..6a1db7c 100644 --- a/internal/ui/uistate/state.go +++ b/internal/ui/uistate/state.go @@ -27,6 +27,7 @@ const ( PageNewTournament PageNewBattle PageGame + PageRules ) type GlobalState struct {