File size: 7,636 Bytes
6bbf552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
from dataclasses import dataclass, field
from random import Random

from budget import Card
from primitives import Effect

STARTING_HP = 20
MAX_ENERGY = 5
HALF_HP = STARTING_HP // 2


@dataclass
class PlayerState:
    name: str
    deck: list[Card]
    hand: list[Card] = field(default_factory=list)
    discard: list[Card] = field(default_factory=list)
    hp: int = STARTING_HP
    energy: int = 0
    block: int = 0
    ward: int = 0
    shield_charge: int = 0
    fatigue: int = 1
    vulnerable: int = 0
    vulnerable_turns: int = 0
    weak: int = 0
    weak_turns: int = 0
    cards_played_this_turn: int = 0


@dataclass
class PendingEffect:
    primitive_id: str
    owner: str
    target: str
    amount: int
    delay: int = 0
    duration: int = 0


@dataclass
class DuelState:
    player: PlayerState
    enemy: PlayerState
    pending: list[PendingEffect] = field(default_factory=list)
    round_number: int = 0
    forced_second: str | None = None


# Create a player state from an ordered deck.
def create_player(name: str, deck: list[Card] | tuple[Card, ...]) -> PlayerState:
    return PlayerState(name=name, deck=list(deck))


# Return the opposing player state.
def opponent(state: DuelState, actor: PlayerState) -> PlayerState:
    return state.enemy if actor is state.player else state.player


# Draw cards, applying escalating fatigue on deck-out.
def draw_cards(player: PlayerState, count: int) -> None:
    for _ in range(count):
        if player.deck:
            player.hand.append(player.deck.pop(0))
        else:
            player.hp -= player.fatigue
            player.fatigue += 1


# Start a paired round with energy refill and one draw each.
def start_round(state: DuelState, rng: Random | None = None) -> tuple[str, str]:
    state.round_number += 1
    for player in (state.player, state.enemy):
        begin_turn(player, state.round_number)
        draw_cards(player, 1)
    advance_pending(state)
    return round_order(state, rng or Random())


# Prepare one player for the current round.
def begin_turn(player: PlayerState, round_number: int) -> None:
    player.energy = min(MAX_ENERGY, round_number)
    player.block = 0
    player.cards_played_this_turn = 0
    tick_statuses(player)


# Tick one-turn status durations.
def tick_statuses(player: PlayerState) -> None:
    if player.vulnerable_turns > 0:
        player.vulnerable_turns -= 1
        if player.vulnerable_turns == 0:
            player.vulnerable = 0
    if player.weak_turns > 0:
        player.weak_turns -= 1
        if player.weak_turns == 0:
            player.weak = 0


# Choose action order for the current paired round.
def round_order(state: DuelState, rng: Random) -> tuple[str, str]:
    if state.forced_second == state.player.name:
        state.forced_second = None
        return (state.enemy.name, state.player.name)
    if state.forced_second == state.enemy.name:
        state.forced_second = None
        return (state.player.name, state.enemy.name)
    names = (state.player.name, state.enemy.name)
    return names if rng.randint(0, 1) == 0 else (names[1], names[0])


# Play a card from hand by index.
def play_card_from_hand(state: DuelState, actor: PlayerState, hand_index: int) -> Card:
    card = actor.hand.pop(hand_index)
    play_card(state, actor, card)
    actor.discard.append(card)
    return card


# Resolve a card if the actor can pay its energy cost.
def play_card(state: DuelState, actor: PlayerState, card: Card) -> None:
    if card.cost > actor.energy:
        raise ValueError("not enough energy")
    actor.energy -= card.cost
    actor.cards_played_this_turn += 1
    target = opponent(state, actor)
    for effect in card.effects:
        apply_effect(state, actor, target, effect)


# Apply one deterministic effect.
def apply_effect(state: DuelState, actor: PlayerState, target: PlayerState, effect: Effect) -> None:
    match effect.primitive_id:
        case "deal":
            deal_damage(actor, target, effect.amount)
        case "burn":
            state.pending.append(PendingEffect("burn", actor.name, target.name, effect.amount, delay=1, duration=effect.duration))
        case "bomb":
            state.pending.append(PendingEffect("bomb", actor.name, target.name, effect.amount, delay=effect.delay))
        case "block":
            actor.block += effect.amount
        case "ward":
            actor.ward += effect.amount
        case "weak":
            target.weak += effect.amount
            target.weak_turns = max(target.weak_turns, effect.duration)
        case "draw":
            draw_cards(actor, effect.amount)
        case "energy":
            actor.energy += effect.amount
        case "initiative":
            state.forced_second = target.name
        case "multi_hit":
            for _ in range(effect.hits):
                deal_damage(actor, target, effect.amount)
        case "vulnerable":
            target.vulnerable += effect.amount
            target.vulnerable_turns = max(target.vulnerable_turns, effect.duration)
        case "conditional":
            damage = conditional_damage(target, effect.amount)
            if damage > 0:
                deal_damage(actor, target, damage)
        case "scaling":
            deal_damage(actor, target, scaling_damage(actor, effect))


# Deal damage through weak, vulnerable, ward, and block.
def deal_damage(actor: PlayerState, target: PlayerState, amount: int) -> int:
    damage = max(0, amount - actor.weak)
    if damage == 0:
        return 0
    damage += target.vulnerable
    if target.ward > 0:
        if damage >= target.ward:
            target.ward = 0
        return 0
    blocked = min(target.block, damage)
    target.shield_charge += blocked // 2
    target.block -= blocked
    target.hp -= damage - blocked
    return damage - blocked


# Return scaling damage and spend shield charge when used.
def scaling_damage(actor: PlayerState, effect: Effect) -> int:
    if effect.condition == "shield_charge":
        damage = effect.amount + actor.shield_charge
        actor.shield_charge = 0
        return damage
    return effect.amount + actor.cards_played_this_turn


# Return missing-HP-scaled conditional damage.
def conditional_damage(target: PlayerState, amount: int) -> int:
    missing_hp = max(0, STARTING_HP - target.hp)
    return amount * missing_hp // STARTING_HP


# Advance all deferred effects by one schedule tick.
def advance_pending(state: DuelState) -> None:
    remaining: list[PendingEffect] = []
    for effect in state.pending:
        if effect.delay > 0:
            effect.delay -= 1
            remaining.append(effect)
        elif effect.primitive_id == "burn":
            deal_damage(named_player(state, effect.owner), named_player(state, effect.target), effect.amount)
            effect.duration -= 1
            if effect.duration > 0:
                remaining.append(effect)
        elif effect.primitive_id == "bomb":
            deal_damage(named_player(state, effect.owner), named_player(state, effect.target), effect.amount)
    state.pending = remaining


# Return a player by name.
def named_player(state: DuelState, name: str) -> PlayerState:
    if state.player.name == name:
        return state.player
    if state.enemy.name == name:
        return state.enemy
    raise KeyError(name)


# Return whether either side has won.
def winner(state: DuelState) -> str | None:
    player_dead = state.player.hp <= 0
    enemy_dead = state.enemy.hp <= 0
    if player_dead and enemy_dead:
        return "draw"
    if enemy_dead:
        return state.player.name
    if player_dead:
        return state.enemy.name
    return None