game: eightball
label: 8-ball pool
players: "2 players"
mode: competitive
num_players: 2
demonstrates: "c · outcome model · executable"
summary: >
  The rules are small and exact; the outcome of a shot is physics. The outcome model replaces
  that physics with P(outcome | difficulty, skill). Positions are abstracted to the
  'leave': how hard the next shot is (easy, medium, hard, …). That way a simulation
  measures rules and tactics instead of collisions.
scope: >
  WPA World Standardized Rules, simplified. All probability parameters are assumptions until they
  are calibrated on real shot data. Ball in hand after a scratch on the break is a parameter,
  because the WPA text and league rules differ on this point.

constants:
  D: {easy: 0.0, medium: 1.5, hard: 3.0, very_hard: 4.5, bih_anywhere: -0.5, bih_kitchen: 1.0}
  POS_REQ: 1.0
  G: 1.0
  P_SCRATCH: 0.03
  A0: 1.5
  SAFE_FOUL: 0.05
  BREAK: {legal_pot: 0.55, legal_dry: 0.30, illegal: 0.04, scratch: 0.08, eight: 0.03}
  TAU: 0.5

state:
  entity_types:
    ball: {attrs: {number: "1..15", group: "enum[solids, stripes, eight]", pos: "(x, y)"}}
  zones:
    table: {kind: plane, owner: global, visibility: public, holds: ball, note: "positions abstracted to the 'leave'"}
  vars:
    shooter: {scope: global, type: ref, init: 0, note: "player 0 breaks"}
    group: {scope: player, type: "enum[open, solids, stripes]", init: open}
    left: {scope: global, type: dict, init_x: "{'solids': 7, 'stripes': 7}", note: "balls per group on the table"}
    ball_in_hand: {scope: global, type: "enum[none, kitchen, anywhere]", init: none}
    leave: {scope: global, type: "enum[easy, medium, hard, very_hard, bih_anywhere, bih_kitchen]", init: medium}
    called: {scope: global, type: "enum[none, shot, safety]", init: none}
    result: {scope: global, type: "enum[pot_ok, pot_bad, foul_pot, miss, foul, safe_good, safe_poor]", init: none}
    inning_over: {scope: global, type: bool, init: false}
    win_reason: {scope: global, type: "enum[none, eight_legal, eight_foul]", init: none}
    skill: {scope: player, type: real, init: 2.0, note: "s; to be calibrated on match data"}
    break_scratch_bih: {scope: global, type: "enum[kitchen, anywhere]", init: kitchen, note: "parameter: WPA reading vs league rules"}
  derived:
    target:
      text: "what the shooter aims at: open table, own group, or the 8 once the group is cleared"
      x: "'open' if group[shooter] == 'open' else ('eight' if left[group[shooter]] == 0 else group[shooter])"

flow:
  id: frame
  label: Frame
  kind: sequence
  children:
    - id: break
      label: Break
      kind: step
      actor: breaker
      actor_x: "0"
      do: break_shot
    - id: innings
      label: Innings
      kind: loop
      until: "8 potted (win or loss)"
      children:
        - id: inning
          label: At the table
          kind: loop
          until: "miss, safety or foul"
          until_x: inning_over
          children:
            - id: place_cb
              label: "Ball in hand: place the cue ball"
              kind: step
              actor: shooter
              actor_x: shooter
              do: place_cue_ball
              note: "Only with ball in hand; skipped otherwise."
            - id: plan
              label: "Call: ball + pocket, or safety"
              kind: step
              actor: shooter
              actor_x: shooter
              do: [call_shot, call_safety]
            - id: shoot
              label: Shoot
              kind: step
              actor: shooter
              actor_x: shooter
              do: execute_shot
            - id: judge
              label: "Judge: continue, switch or foul"
              kind: step
              do: judge_shot
        - id: switch
          label: Other player to the table
          kind: step
          do: switch_shooter

actions:
  break_shot:
    effect:
      - {op: sample, model: break}
  place_cue_ball:
    pre: "ball in hand"
    pre_x: "ball_in_hand != 'none'"
    effect:
      - {op: set, var: leave, value_x: "'bih_anywhere' if ball_in_hand == 'anywhere' else 'bih_kitchen'"}
      - {op: set, var: ball_in_hand, value: none}
  call_shot:
    effect: [{op: set, var: called, value: shot}]
  call_safety:
    effect: [{op: set, var: called, value: safety}]
  execute_shot:
    effect:
      - op: if
        cond: "safety called"
        cond_x: "called == 'safety'"
        then: [{op: sample, model: safety}]
        else: [{op: sample, model: shot}]
  judge_shot:
    effect:
      - op: if
        cond: "foul on the shot that pots the 8"
        cond_x: "result == 'foul_pot' and target == 'eight'"
        then: [{op: set, var: win_reason, value: eight_foul}]
      - op: if
        cond: "foul"
        cond_x: "result in ('foul', 'foul_pot')"
        then:
          - {op: set, var: ball_in_hand, value: anywhere}
          - {op: set, var: inning_over, value: true}
          - {op: emit, event: foul}
      - op: if
        cond: "called ball legally potted"
        cond_x: "result in ('pot_ok', 'pot_bad')"
        then:
          - {op: emit, event: legal_pot}
          - op: if
            cond: "ball from own group"
            cond_x: "target in ('solids', 'stripes')"
            then: [{op: add, var: "left[target]", amount: -1}]
          - {op: set, var: leave, value_x: "'easy' if result == 'pot_ok' else 'hard'"}
      - op: if
        cond: "missed"
        cond_x: "result == 'miss'"
        then: [{op: set, var: leave, value: medium}, {op: set, var: inning_over, value: true}]
      - op: if
        cond: "good safety"
        cond_x: "result == 'safe_good'"
        then: [{op: set, var: leave, value: very_hard}, {op: set, var: inning_over, value: true}]
      - op: if
        cond: "poor safety"
        cond_x: "result == 'safe_poor'"
        then: [{op: set, var: leave, value: medium}, {op: set, var: inning_over, value: true}]
  switch_shooter:
    effect:
      - {op: set, var: shooter, value_x: "1 - shooter"}
      - {op: set, var: inning_over, value: false}

outcomes:
  shot:
    skill: true
    doc: >
      Called shot. d depends on the position left behind; with geometry it becomes
      b0 + b1·theta + b2·l_ob + b3·l_cb. Position play is a second, stricter threshold.
    inputs:
      d: "difficulty of the position (leave)"
      s: "skill of the shooter"
    derived:
      p_pot: "σ(s − d)"
      p_pos: "σ(s − d − g·pos)"
    derived_x:
      s: "skill[shooter]"
      d: "D[leave]"
      p_pot: "sigmoid(s - d)"
      p_pos: "sigmoid(s - d - G * POS_REQ)"
    outcomes:
      - {id: pot_position, p: "p_pot · p_pos · (1 − p_scratch)", p_x: "p_pot * p_pos * (1 - P_SCRATCH)", effect: [{op: set, var: result, value: pot_ok}]}
      - {id: pot_no_position, p: "p_pot · (1 − p_pos) · (1 − p_scratch)", p_x: "p_pot * (1 - p_pos) * (1 - P_SCRATCH)", effect: [{op: set, var: result, value: pot_bad}]}
      - {id: pot_scratch, p: "p_pot · p_scratch", p_x: "p_pot * P_SCRATCH", effect: [{op: set, var: result, value: foul_pot}]}
      - {id: miss, p: "(1 − p_pot) · (1 − p_scratch)", p_x: "(1 - p_pot) * (1 - P_SCRATCH)", effect: [{op: set, var: result, value: miss}]}
      - {id: miss_scratch, p: "(1 − p_pot) · p_scratch", p_x: "(1 - p_pot) * P_SCRATCH", effect: [{op: set, var: result, value: foul}]}
  safety:
    skill: true
    doc: Leaves the opponent a hard position, or fails.
    inputs: {s: skill}
    derived:
      p_good: "σ(s − a0)"
    derived_x:
      s: "skill[shooter]"
      p_good: "sigmoid(s - A0)"
    outcomes:
      - {id: safe_good, p: "p_good · (1 − p_foul)", p_x: "p_good * (1 - SAFE_FOUL)", effect: [{op: set, var: result, value: safe_good}]}
      - {id: safe_poor, p: "(1 − p_good) · (1 − p_foul)", p_x: "(1 - p_good) * (1 - SAFE_FOUL)", effect: [{op: set, var: result, value: safe_poor}]}
      - {id: safe_foul, p: "p_foul", p_x: "SAFE_FOUL", effect: [{op: set, var: result, value: foul}]}
  break:
    skill: true
    doc: >
      Opening shot. Fixed probabilities in v0.3; with data they become a function of
      break skill. After an illegal break the opponent accepts the table; an 8 on the break
      is spotted and the breaker continues.
    inputs: {}
    outcomes:
      - id: legal_pot
        p: "p_legal_pot"
        p_x: "BREAK['legal_pot']"
        effect:
          - {op: roll, die: "group of the potted ball", die_x: "['solids', 'stripes']", bind: g}
          - {op: add, var: "left[g]", amount: -1}
          - {op: set, var: leave, value: medium}
          - {op: set, var: inning_over, value: false}
      - id: legal_dry
        p: "p_legal_dry"
        p_x: "BREAK['legal_dry']"
        effect: [{op: set, var: leave, value: medium}, {op: set, var: inning_over, value: true}]
      - id: illegal_break
        p: "p_illegal"
        p_x: "BREAK['illegal']"
        effect: [{op: set, var: leave, value: medium}, {op: set, var: inning_over, value: true}]
      - id: break_scratch
        p: "p_scratch"
        p_x: "BREAK['scratch']"
        effect:
          - {op: set, var: ball_in_hand, value_x: "break_scratch_bih"}
          - {op: set, var: inning_over, value: true}
      - id: eight_on_break
        p: "p_eight"
        p_x: "BREAK['eight']"
        effect: [{op: set, var: leave, value: medium}, {op: set, var: inning_over, value: false}]

agents:
  threshold:
    kind: heuristic
    roles: [shooter]
    sees: [table]
    policy_x: "'call_shot' if sigmoid(skill[decider] - D[leave]) >= TAU else 'call_safety'"
    prompt: "Attack when the pot chance is at least TAU, otherwise play safe."
  always_attack:
    kind: heuristic
    roles: [shooter]
    sees: [table]
    policy_x: "'call_shot'"

triggers:
  - id: group_assignment
    source: rule
    event: legal_pot
    timing: after
    if: "table open and this is not the break"
    if_x: "target == 'open'"
    do:
      - {op: set, var: "group[shooter]", value_x: "'solids' if left['solids'] >= left['stripes'] else 'stripes'"}
      - {op: set, var: "group[1 - shooter]", value_x: "'stripes' if group[shooter] == 'solids' else 'solids'"}
      - {op: emit, event: group_assigned}
  - id: eight_pot
    source: rule
    event: legal_pot
    timing: after
    if: "called ball is the 8"
    if_x: "target == 'eight'"
    do: [{op: set, var: win_reason, value: eight_legal}]

open_questions:
  - id: q_break_scratch
    about: state.vars.break_scratch_bih
    question: "After a scratch on a legal break, may the opponent place the cue ball anywhere, or only behind the head string?"
    source: "The WPA rule text is read differently on AzBilliards; league rules differ"

end:
  - {id: win_eight, check: immediate, if: "8 legally in the called pocket after own group is cleared", if_x: "win_reason == 'eight_legal'", who_x: shooter, result: win}
  - {id: loss_foul, check: immediate, if: "foul on the shot that pots the 8", if_x: "win_reason == 'eight_foul'", who_x: shooter, result: loss}
  - {id: loss_early, check: immediate, if: "8 potted before own group is cleared, or 8 in an uncalled pocket (not modelled in the abstraction)", result: loss}
  - {id: stalemate, check: immediate, if: "stalemate (not modelled)", result: draw}
