|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) |
| 3 | += Migration Lesson: idaptik `PlayerHP.res` → `PlayerHP.affine` |
| 4 | +:revdate: 2026-05-02 |
| 5 | +:icons: font |
| 6 | +:source-highlighter: rouge |
| 7 | + |
| 8 | +[NOTE] |
| 9 | +==== |
| 10 | +**Status: design-translated, toolchain run pending.** The translation is |
| 11 | +written to be compilable against AffineScript v0.1.0 (Int arithmetic, no |
| 12 | +external module imports), following the same Float→Int convention |
| 13 | +xref:idaptik-hitbox.adoc[idaptik-hitbox] established. A Reproducibility |
| 14 | +block matching hitbox's will be filled in by the next translator who runs |
| 15 | +this through `affinescript-host-shim.sh`. |
| 16 | +
|
| 17 | +Picked because it stress-tests the migration playbook's central decision — |
| 18 | +*`mut` vs `State` effect* — on a record with three orthogonal aspects |
| 19 | +bundled into one struct. Unlike |
| 20 | +xref:idaptik-user-settings.adoc[idaptik-user-settings], PlayerHP has no |
| 21 | +external dependencies; unlike xref:idaptik-hitbox.adoc[idaptik-hitbox], it |
| 22 | +has interesting state. |
| 23 | +==== |
| 24 | + |
| 25 | +== The original (88 lines, ReScript) |
| 26 | + |
| 27 | +[source,rescript] |
| 28 | +---- |
| 29 | +type t = { |
| 30 | + mutable current: float, |
| 31 | + mutable max: float, |
| 32 | + mutable invincibleTimer: float, |
| 33 | + mutable knockbackVelX: float, |
| 34 | + mutable knockbackVelY: float, |
| 35 | + mutable knockbackTimer: float, |
| 36 | +} |
| 37 | +
|
| 38 | +let make = (~con: float): t => { |
| 39 | + let maxHP = 80.0 +. 20.0 *. (con /. 100.0) |
| 40 | + { current: maxHP, max: maxHP, invincibleTimer: 0.0, |
| 41 | + knockbackVelX: 0.0, knockbackVelY: 0.0, knockbackTimer: 0.0 } |
| 42 | +} |
| 43 | +
|
| 44 | +let isInvincible = (hp: t): bool => hp.invincibleTimer > 0.0 |
| 45 | +let isAlive = (hp: t): bool => hp.current > 0.0 |
| 46 | +
|
| 47 | +let takeDamage = (hp: t, ~amount, ~fromX, ~playerX): unit => { |
| 48 | + if !isInvincible(hp) { |
| 49 | + hp.current = Math.max(0.0, hp.current -. amount) |
| 50 | + hp.invincibleTimer = Knockback.iFrames |
| 51 | + let dx = playerX -. fromX |
| 52 | + let direction = if dx >= 0.0 { 1.0 } else { -1.0 } |
| 53 | + hp.knockbackVelX = direction *. Knockback.speed |
| 54 | + hp.knockbackVelY = -80.0 |
| 55 | + hp.knockbackTimer = Knockback.duration |
| 56 | + } |
| 57 | +} |
| 58 | +
|
| 59 | +let update = (hp: t, ~dt): (float, float) => { /* decay timers, return knockback velocity */ } |
| 60 | +---- |
| 61 | + |
| 62 | +A single mutable record carrying three orthogonal concerns: **health** |
| 63 | +(`current`, `max`), **invincibility** (`invincibleTimer`), and |
| 64 | +**knockback** (`knockbackVelX`, `knockbackVelY`, `knockbackTimer`). Six |
| 65 | +mutable fields; one type. The labelled args (`~con`, `~amount`, `~fromX`, |
| 66 | +`~playerX`, `~dt`) are also doing work the type system isn't. |
| 67 | + |
| 68 | +== The decision the playbook forces you to make |
| 69 | + |
| 70 | +The playbook's |
| 71 | +xref:../../migration-playbook.adoc#decision-criteria[`mut` vs `State` |
| 72 | +effect] criterion gives two candidates: |
| 73 | + |
| 74 | +. **`mut PlayerHP`** — caller owns the value, mutation is local to each |
| 75 | + function call, the HP record is threaded through call sites that need |
| 76 | + it. |
| 77 | +. **`State[Player]` effect** — the HP record lives inside an effect |
| 78 | + handler, callers say `Player.take_damage(15)` without holding a |
| 79 | + reference. |
| 80 | + |
| 81 | +But there's a third candidate the playbook doesn't name explicitly: it |
| 82 | +falls out of the playbook's *cardinal rule* (re-decompose, do not |
| 83 | +transliterate) plus the rejection of monolithic instances: |
| 84 | + |
| 85 | +[start=3] |
| 86 | +. **Decompose the type by aspect first, then choose mutation discipline |
| 87 | + per aspect.** `HP`, `IFrames`, `Knockback` are three independent |
| 88 | + state-machines that happen to be triggered by the same event. |
| 89 | + |
| 90 | +The right answer depends on whether the three aspects are genuinely |
| 91 | +independent or whether their coupling *is* the program. Two pieces of |
| 92 | +evidence point at "genuinely independent": |
| 93 | + |
| 94 | +* `update(dt)` decays the i-frame timer and the knockback timer |
| 95 | + separately. They share no state; they just both happen to decay. |
| 96 | +* The renderer reads `current`/`max` for the HP bar, the physics code |
| 97 | + reads `knockbackVelX`/`Y`, the damage code reads `invincibleTimer`. |
| 98 | + No single consumer reads all three. |
| 99 | + |
| 100 | +The bundling in the original is not a design — it is a side-effect of |
| 101 | +ReScript's "one record per gameplay subsystem" idiom. |
| 102 | + |
| 103 | +== The translation (aspect-decomposed, `mut`-based) |
| 104 | + |
| 105 | +[source,affinescript] |
| 106 | +---- |
| 107 | +// HP measured in tenths (max baseline = 1000 = 100.0 HP). |
| 108 | +// Timers measured in milliseconds (16ms ≈ 60fps frame; 1000ms = 1.0s). |
| 109 | +// Float arithmetic is unsupported in AffineScript v0.1.0 (see hitbox lesson). |
| 110 | +
|
| 111 | +struct HP { current: Int, max: Int } |
| 112 | +struct IFrames { remaining_ms: Int } |
| 113 | +struct Knockback { vel_x: Int, vel_y: Int, remaining_ms: Int } |
| 114 | +
|
| 115 | +struct Player { |
| 116 | + hp: HP, |
| 117 | + iframes: IFrames, |
| 118 | + knockback: Knockback, |
| 119 | +} |
| 120 | +
|
| 121 | +// Constants — module-style namespacing |
| 122 | +struct Damage { |
| 123 | + static let robo_dog_contact: Int = 150 |
| 124 | + static let guard_dog_bite: Int = 100 |
| 125 | + static let assassin_strike: Int = 350 |
| 126 | + static let guard_melee: Int = 100 |
| 127 | +} |
| 128 | +
|
| 129 | +struct Tuning { |
| 130 | + static let knockback_speed: Int = 200 |
| 131 | + static let knockback_duration_ms: Int = 300 |
| 132 | + static let iframe_duration_ms: Int = 1000 |
| 133 | + static let knockback_pop_y: Int = -80 |
| 134 | +} |
| 135 | +
|
| 136 | +// CON is a 0-200 scaling factor; baseline 100 → 1000 (= 100.0 HP in tenths). |
| 137 | +fn make(con: Int) -> Player { |
| 138 | + let max_hp = 800 + 2 * con |
| 139 | + Player { |
| 140 | + hp: HP { current: max_hp, max: max_hp }, |
| 141 | + iframes: IFrames { remaining_ms: 0 }, |
| 142 | + knockback: Knockback { vel_x: 0, vel_y: 0, remaining_ms: 0 }, |
| 143 | + } |
| 144 | +} |
| 145 | +
|
| 146 | +fn is_invincible(p: ref Player) -> Bool { p.iframes.remaining_ms > 0 } |
| 147 | +fn is_alive(p: ref Player) -> Bool { p.hp.current > 0 } |
| 148 | +
|
| 149 | +// Position is a struct, not two same-typed labelled args: |
| 150 | +struct Pos { x: Int } |
| 151 | +
|
| 152 | +fn take_damage(p: mut Player, amount: Int, source: Pos, player: Pos) -> () { |
| 153 | + if is_invincible(p) { return } |
| 154 | +
|
| 155 | + // 1. HP — clamped subtract |
| 156 | + p.hp.current = max(0, p.hp.current - amount) |
| 157 | +
|
| 158 | + // 2. IFrames — start the invincibility window |
| 159 | + p.iframes.remaining_ms = Tuning.iframe_duration_ms |
| 160 | +
|
| 161 | + // 3. Knockback — direction is +1 right of source, -1 left of source |
| 162 | + let direction = if player.x >= source.x { 1 } else { -1 } |
| 163 | + p.knockback.vel_x = direction * Tuning.knockback_speed |
| 164 | + p.knockback.vel_y = Tuning.knockback_pop_y |
| 165 | + p.knockback.remaining_ms = Tuning.knockback_duration_ms |
| 166 | +} |
| 167 | +
|
| 168 | +struct Velocity { x: Int, y: Int } |
| 169 | +
|
| 170 | +// Tick all three timers; return knockback velocity for this frame. |
| 171 | +fn update(p: mut Player, dt_ms: Int) -> Velocity { |
| 172 | + // IFrames decay |
| 173 | + if p.iframes.remaining_ms > 0 { |
| 174 | + p.iframes.remaining_ms = max(0, p.iframes.remaining_ms - dt_ms) |
| 175 | + } |
| 176 | +
|
| 177 | + // Knockback decay + velocity emission |
| 178 | + if p.knockback.remaining_ms > 0 { |
| 179 | + p.knockback.remaining_ms = max(0, p.knockback.remaining_ms - dt_ms) |
| 180 | + let v = Velocity { x: p.knockback.vel_x, y: p.knockback.vel_y } |
| 181 | + if p.knockback.remaining_ms <= 0 { |
| 182 | + p.knockback.vel_x = 0 |
| 183 | + p.knockback.vel_y = 0 |
| 184 | + } |
| 185 | + v |
| 186 | + } else { |
| 187 | + Velocity { x: 0, y: 0 } |
| 188 | + } |
| 189 | +} |
| 190 | +---- |
| 191 | + |
| 192 | +== What changed |
| 193 | + |
| 194 | +=== 1. One monolithic struct → three aspect structs |
| 195 | + |
| 196 | +`HP`, `IFrames`, `Knockback` are nominally distinct. The compiler refuses |
| 197 | +`is_invincible(some_hp)` — only a `Player` (or, with refactoring, a |
| 198 | +`ref IFrames`) is acceptable. Each aspect has a clear lifetime, a clear |
| 199 | +mutator, and a clear reader. A future contributor adding e.g. a *poison* |
| 200 | +status effect drops in alongside the existing three rather than mutating |
| 201 | +a six-field god-struct. |
| 202 | + |
| 203 | +=== 2. `mut Player`, not `State[Player]` effect |
| 204 | + |
| 205 | +This is the playbook's |
| 206 | +xref:../../migration-playbook.adoc#decision-criteria[`mut` vs `State`] |
| 207 | +decision applied carefully. Two consumers (combat and physics) need to |
| 208 | +mutate `Player`; both are called from the same game-loop tick under a |
| 209 | +single owner (the `Engine` or `World`). No third caller observes the |
| 210 | +mutation across the tick boundary. **Coupling is local to one frame.** |
| 211 | + |
| 212 | +If a future system needed to react to "player got hit" *between* combat |
| 213 | +and physics in the same tick, that would be the moment to lift to a |
| 214 | +`State[Player]` effect (or, more honestly, to a `Damage` effect with |
| 215 | +handlers in both subsystems). The point of `mut` here is that we get |
| 216 | +explicit local mutation now without fabricating a global effect that |
| 217 | +isn't doing any work. |
| 218 | + |
| 219 | +=== 3. Tuning constants in `static let` slots, not bare module values |
| 220 | + |
| 221 | +ReScript's `module Damage = { let roboDogContact = 15.0 }` works because |
| 222 | +modules are namespaces. AffineScript's idiomatic equivalent (per the |
| 223 | +hitbox lesson and the v0.1.0 surface) is `static let` inside a `struct`, |
| 224 | +which gives the same `Damage.robo_dog_contact` access path with a |
| 225 | +typed-WASM-friendly representation. |
| 226 | + |
| 227 | +=== 4. Same-typed labelled args → struct types |
| 228 | + |
| 229 | +`takeDamage(hp, ~amount, ~fromX, ~playerX)` becomes |
| 230 | +`take_damage(p, amount, source: Pos, player: Pos)`. The labels `fromX` |
| 231 | +and `playerX` were doing real work — disambiguating two `float` values — |
| 232 | +which is exactly the case the |
| 233 | +xref:idaptik-hitbox.adoc[hitbox lesson] flagged as the canonical |
| 234 | +"labels-want-to-be-types" pattern. |
| 235 | + |
| 236 | +=== 5. Float → Int with documented precision compromise |
| 237 | + |
| 238 | +* HP: tenths (max baseline 1000 = 100.0 HP). Damage values rescale by 10 |
| 239 | + (`15.0` → `150`). One-decimal precision is fine for game logic. |
| 240 | +* Timers: milliseconds. 60fps `dt` is `~16ms`. The original `0.3`-second |
| 241 | + knockback duration becomes `300`. The original `1.0`-second i-frame |
| 242 | + duration becomes `1000`. |
| 243 | + |
| 244 | +This is the same compromise xref:idaptik-hitbox.adoc[idaptik-hitbox] |
| 245 | +documented for collision rectangles. **Do not silently change the unit |
| 246 | +of a public field** — the source-comment header should declare the |
| 247 | +scaling factors. When AffineScript gains Float-arithmetic operators, the |
| 248 | +file can be reverted to floats with a one-pass rewrite. |
| 249 | + |
| 250 | +== Playbook verdict |
| 251 | + |
| 252 | +=== Held up cleanly |
| 253 | + |
| 254 | +* xref:../../migration-playbook.adoc#anti-patterns[The "monolithic |
| 255 | + instance" anti-pattern] — six mutable fields under one type *is* the |
| 256 | + pattern. Aspect-decomposition follows directly from "smaller, more |
| 257 | + declarations." |
| 258 | +* xref:../../migration-playbook.adoc#decision-criteria[`mut` vs `State` |
| 259 | + decision criterion] — gave a defensible answer once we asked "do |
| 260 | + consumers observe the mutation across calls or within a tick?" |
| 261 | +* The Float → Int compromise *and* the requirement to document the |
| 262 | + scaling factors — flowed directly from xref:idaptik-hitbox.adoc[the |
| 263 | + hitbox lesson's existing rule]. |
| 264 | + |
| 265 | +=== Gap surfaced |
| 266 | + |
| 267 | +**Aspect-decomposition vs single-record translation is not named in the |
| 268 | +playbook.** The cardinal rule says "more, smaller declarations" and the |
| 269 | +file-buffer anti-pattern shows two-types-from-one — but a *six-field |
| 270 | +mutable record with three orthogonal concerns* is a distinct pattern |
| 271 | +that wants its own row in the source-pattern index. |
| 272 | + |
| 273 | +*Proposed for a v1.2 follow-up:* a row in the ReScript pattern index for |
| 274 | +"record with N orthogonal aspects bundled into one type" with the |
| 275 | +decomposition "fission into N nominally distinct types whose lifetimes |
| 276 | +and consumers differ; compose them in a parent type only when they |
| 277 | +genuinely share a lifetime." This is *the* central pattern of game-state |
| 278 | +migration and deserves explicit naming. |
| 279 | + |
| 280 | +=== Decision criterion not exercised |
| 281 | + |
| 282 | +The xref:../../migration-playbook.adoc#decision-criteria[coupled-writes |
| 283 | +subsection added in v1.1] did not apply here — PlayerHP has no |
| 284 | +persistence and no IO. That is information about the v1.1 addition's |
| 285 | +scope, not a gap. |
| 286 | + |
| 287 | +== What this lesson covers, generalised |
| 288 | + |
| 289 | +When translating a `.res` file with **multi-field mutable state**: |
| 290 | + |
| 291 | +. **Look at the fields by consumer, not by name.** If different |
| 292 | + consumers read disjoint subsets of the fields, the bundling is |
| 293 | + incidental, not structural. Fission. |
| 294 | +. **Choose `mut` vs `State` by call-site distance.** If all mutators |
| 295 | + run inside one tick under one owner, `mut` is honest. If the |
| 296 | + mutation must be observed across an asynchronous boundary, lift to |
| 297 | + `State`. |
| 298 | +. **Do not lift to `State` to *organise* the code.** Effects are for |
| 299 | + observability across callers, not for namespace cleanliness. |
| 300 | +. **Document Float→Int scaling factors at the file header.** The next |
| 301 | + translator inherits the precision compromise; do not make them |
| 302 | + rediscover it. |
| 303 | + |
| 304 | +== Reproducibility |
| 305 | + |
| 306 | +[NOTE] |
| 307 | +==== |
| 308 | +This lesson is design-translated; the toolchain run is pending. The |
| 309 | +steps below match xref:idaptik-hitbox.adoc#reproducibility[the hitbox |
| 310 | +lesson's Reproducibility section] and should produce the same shape of |
| 311 | +output. |
| 312 | +==== |
| 313 | + |
| 314 | +[source,bash] |
| 315 | +---- |
| 316 | +$ just affinescript-stage |
| 317 | +$ ./scripts/affinescript-host-shim.sh check src/app/combat/PlayerHP.affine |
| 318 | +# Expected: "Type checking passed" |
| 319 | +$ ./scripts/affinescript-host-shim.sh compile src/app/combat/PlayerHP.affine -o src/app/combat/PlayerHP.wasm |
| 320 | +# Expected: "Compiled src/app/combat/PlayerHP.affine -> src/app/combat/PlayerHP.wasm (WASM)" |
| 321 | +---- |
| 322 | + |
| 323 | +When the toolchain run completes, replace this NOTE with the actual |
| 324 | +output (matching hitbox's format) and link the resulting `.wasm` size in |
| 325 | +the file header. If the run fails on a v0.1.0 limitation not anticipated |
| 326 | +here, **edit the lesson** — that is exactly the case study a future |
| 327 | +translator most needs. |
0 commit comments