state.gno
9.22 Kb · 320 lines
1// Package engine is the pure, deterministic rules engine of a gunfight of
2// two to four characters: plan parsing and validation, and the 5-segment
3// resolution of a turn. It has no chain dependency so it can be unit-tested
4// with gno test.
5package engine
6
7import "gno.land/p/g1th0masp67zfs9lg6d34qhghldhg6kjcqjuv7c2/gnoslinger/hex/v0"
8
9const (
10 // MaxActionPoints is the time points a plan may spend in a turn (rule 7.3).
11 MaxActionPoints = 5
12 // Segments is the number of segments in a turn (rule 4.1).
13 Segments = 5
14 // EndOfTurn is the Seg of events recorded after segment 5 (cancelled
15 // actions, passing out, the end-of-showdown check); turn-start events
16 // (SERIOUS fatigue, aim lost on the first reveal) have Seg 0.
17 EndOfTurn = Segments + 1
18 // MinPlayers and MaxPlayers bound the seats of a game: the showdown's
19 // characters (Setups) fill the seats in order, each a side of its own.
20 MinPlayers = 2
21 MaxPlayers = 4
22)
23
24// Status is the participation state of a character.
25type Status int
26
27const (
28 // Alive characters plan and act.
29 Alive Status = iota
30 // Out characters (surrendered, or out of time) are skipped by Resolve.
31 Out
32 // Killed characters are out of the game (14.6).
33 Killed
34 // PassedOut characters carried more delay than endurance for two turns (14.2).
35 PassedOut
36 // RanAway characters moved off the map (15.5): out of the showdown,
37 // their hex the edge hex they left from.
38 RanAway
39)
40
41// String returns the status name used in JSON views.
42func (s Status) String() string {
43 switch s {
44 case Alive:
45 return "alive"
46 case Out:
47 return "out"
48 case Killed:
49 return "killed"
50 case PassedOut:
51 return "passed_out"
52 case RanAway:
53 return "ran_away"
54 }
55 return "?"
56}
57
58// StartEndurance is the endurance boxes a character starts with (14.2).
59const StartEndurance = 20
60
61// MaxAim is the most AIM points a character can hold on a target (12.3).
62const MaxAim = 8
63
64// NoSeat is the seat value for "nobody": AIM markers on a hex, a wound or a
65// death nobody inflicted.
66const NoSeat = -1
67
68// Character is the mutable state of one gunslinger.
69type Character struct {
70 Hex string
71 Facing hex.Dir
72 Down bool
73 Delay int // delay points on the sheet (rule 6)
74 Status Status
75 // PlayedFoot and PlayedRun record whether the current turn's plan holds
76 // a foot action / a Run, whether or not it executes ([H12], rules 13.61-62).
77 PlayedFoot bool
78 PlayedRun bool
79 // RanLastTurn is PlayedRun carried over from the previous turn: Sprint
80 // needs a Run on the previous turn as well (rule 9.23, "build up speed").
81 RanLastTurn bool
82 // Dropped records a DROP penalty this turn ("Move" target status, 13.61).
83 Dropped bool
84 // Guns carried (section 10).
85 Guns Guns
86 // Aim is the AIM points on the target (12.2). AimAt is the seat of the
87 // character the markers follow, wherever it goes (12.3), NoSeat when
88 // they sit on the hex AimHex. Both are meaningless while Aim is 0.
89 Aim int
90 AimAt int
91 AimHex string
92 // Endurance is the boxes left on the sheet (14.2); wound and fatigue
93 // cards cross them off.
94 Endurance int
95 // Exhausted counts the consecutive turn ends with more delay than
96 // endurance; two in a row pass the character out (14.2).
97 Exhausted int
98 // Permanent wounds (14.12): fatigue cards per turn, aim time penalties
99 // per hand, fatigue cards per hex moved upright.
100 Serious int
101 GunArm int
102 OtherArm int
103 Leg int
104 // Who did it, for the victory points (15.2, 15.21, 15.3), by seat: the
105 // seats credited with the kill (several when their KILLs landed in the
106 // same segment, or tied for the most SERIOUS points), the SERIOUS and
107 // LEG points each seat's wounds gave (the fatigue those wounds cost
108 // later is put down to it), and the endurance boxes each seat's hits
109 // crossed off.
110 Killers [MaxPlayers]bool
111 SeriousFrom [MaxPlayers]int
112 LegFrom [MaxPlayers]int
113 Hurt [MaxPlayers]int
114}
115
116// GroundGun is a weapon lying in a hex (10.21). ID identifies it on the
117// ground for the whole game (plans refer to it as "g<ID>"); the gun's own
118// ID was its previous owner's and is reassigned when picked up.
119type GroundGun struct {
120 ID int
121 Hex string
122 Gun Gun
123}
124
125// State is the whole game state the engine acts on: one character per
126// seat, in seat order.
127type State struct {
128 Chars []Character
129 Ground []GroundGun
130 GroundSeq int // last ground id handed out
131}
132
133// NewState is a showdown of n characters before anyone set up: each
134// upright with its starting guns and endurance, standing nowhere yet (Hex
135// "") until SetUp gives it its hex and facing ([H3], setup.gno).
136func NewState(n int) State {
137 chars := make([]Character, n)
138 for i := range chars {
139 chars[i] = newCharacter()
140 }
141 return State{Chars: chars}
142}
143
144// newCharacter is a character's sheet at the start of a showdown.
145func newCharacter() Character {
146 return Character{Guns: startingGuns(), Endurance: StartEndurance, AimAt: NoSeat}
147}
148
149// char is the character of a seat, nil for a seat the state does not have.
150func (s *State) char(seat int) *Character {
151 if seat < 0 || seat >= len(s.Chars) {
152 return nil
153 }
154 return &s.Chars[seat]
155}
156
157// CloneChars copies the characters field by field. A plain copy of the
158// slice is not enough on a node: a character written earlier in the same
159// transaction stays shared with the live state, so a turn's stored start
160// would show its end position.
161func CloneChars(cs []Character) []Character {
162 out := make([]Character, len(cs))
163 for i, c := range cs {
164 out[i] = Character{
165 Hex: c.Hex,
166 Facing: c.Facing,
167 Down: c.Down,
168 Delay: c.Delay,
169 Status: c.Status,
170 PlayedFoot: c.PlayedFoot,
171 PlayedRun: c.PlayedRun,
172 RanLastTurn: c.RanLastTurn,
173 Dropped: c.Dropped,
174 Aim: c.Aim,
175 AimAt: c.AimAt,
176 AimHex: c.AimHex,
177 Endurance: c.Endurance,
178 Exhausted: c.Exhausted,
179 Serious: c.Serious,
180 GunArm: c.GunArm,
181 OtherArm: c.OtherArm,
182 Leg: c.Leg,
183 Killers: c.Killers,
184 SeriousFrom: c.SeriousFrom,
185 LegFrom: c.LegFrom,
186 Hurt: c.Hurt,
187 }
188 for j, g := range c.Guns {
189 out[i].Guns[j] = Gun{ID: g.ID, Type: g.Type, Location: g.Location, Cocked: g.Cocked, Shells: g.Shells, Jammed: g.Jammed, Exploded: g.Exploded}
190 }
191 }
192 return out
193}
194
195// Budget is the time points seat p may plan this turn: the carried delay
196// counts against the 5 points ([H5], rule 7.3).
197func (s State) Budget(p int) int {
198 b := MaxActionPoints - s.Chars[p].Delay
199 if b < 0 {
200 return 0
201 }
202 return b
203}
204
205// AliveCount counts the characters still in the fight.
206func AliveCount(s State) int {
207 n := 0
208 for _, c := range s.Chars {
209 if c.Status == Alive {
210 n++
211 }
212 }
213 return n
214}
215
216// EndTurn is the end-of-turn upkeep (rule 5.6): half of the delay points
217// are removed, rounded up, the per-turn flags are cleared, and a character
218// left with more delay than endurance for the second turn in a row passes
219// out (14.2; checked after the halving). The state given is left as it is.
220func EndTurn(s State) (State, []Event) {
221 s.Chars = CloneChars(s.Chars)
222 var evs []Event
223 for p := range s.Chars {
224 c := &s.Chars[p]
225 c.Delay -= (c.Delay + 1) / 2
226 c.RanLastTurn = c.PlayedRun
227 c.PlayedFoot = false
228 c.PlayedRun = false
229 c.Dropped = false
230 if c.Status != Alive {
231 continue
232 }
233 if c.Delay > c.Endurance {
234 c.Exhausted++
235 } else {
236 c.Exhausted = 0
237 }
238 if c.Exhausted >= 2 {
239 c.Status = PassedOut
240 c.Down = true
241 clearAim(c)
242 evs = append(evs, Event{Seg: EndOfTurn, Player: p, Kind: EvPassOut, Down: true})
243 dropAllGuns(p, c, EndOfTurn, &s, &evs)
244 }
245 }
246 return s, evs
247}
248
249// EndShowdown is the end-of-game check (14.5): every character with SERIOUS
250// wounds draws a result card and is killed when its DELAY number plus the
251// wound points reaches four; the enemy who gave the most SERIOUS points
252// gets the kill, enemies tied for the most share it (15.21). The state
253// given is left as it is.
254func EndShowdown(s State, d Drawer) (State, []Event) {
255 s.Chars = CloneChars(s.Chars)
256 var evs []Event
257 for p := range s.Chars {
258 c := &s.Chars[p]
259 if c.Status != Alive || c.Serious == 0 {
260 continue
261 }
262 card := d.Draw()
263 n := 0
264 if card.Delay.IsPoints() {
265 n = int(card.Delay)
266 }
267 evs = append(evs, Event{Seg: EndOfTurn, Player: p, Kind: EvSeriousCheck, Card: card.N, N: n + c.Serious})
268 if n+c.Serious >= 4 {
269 kill(c, mostSeats(c.SeriousFrom))
270 evs = append(evs, Event{Seg: EndOfTurn, Player: p, Kind: EvWound, Result: string(Kill)})
271 dropAllGuns(p, c, EndOfTurn, &s, &evs)
272 }
273 }
274 return s, evs
275}
276
277// kill takes a character out of the game (14.6), killed by the seats
278// marked in by (none when nobody's hit did it): down, no delay marker, no
279// endurance left.
280func kill(c *Character, by [MaxPlayers]bool) {
281 c.Status = Killed
282 c.Down = true
283 clearAim(c)
284 c.Delay = 0
285 c.Endurance = 0
286 c.Killers = by
287}
288
289// seat marks one seat, none for NoSeat.
290func seat(p int) (out [MaxPlayers]bool) {
291 if p >= 0 && p < MaxPlayers {
292 out[p] = true
293 }
294 return out
295}
296
297// mostSeats marks the seats with the most points, every one of them on a
298// tie; none when nobody has any.
299func mostSeats(pts [MaxPlayers]int) (out [MaxPlayers]bool) {
300 most := 0
301 for _, n := range pts {
302 if n > most {
303 most = n
304 }
305 }
306 if most == 0 {
307 return out
308 }
309 for q, n := range pts {
310 out[q] = n == most
311 }
312 return out
313}
314
315// clearAim takes the AIM markers off the sheet.
316func clearAim(c *Character) {
317 c.Aim = 0
318 c.AimAt = NoSeat
319 c.AimHex = ""
320}