Cards become JSON data in course/cards (one file per card, validated against card.schema.json and the catalog); the M5 references load them via registerCards and interpret effects, replacing content closures. Adds assemble(base, donor, half), the assemble/buffHand effects with an injected chooser, Fleshshape as the data-only Expansion Drop, and stages course/cards in verify-references and start-module. Co-Authored-By: Claude Opus 5.5 <[email protected]>
379 lines
12 KiB
Rust
379 lines
12 KiB
Rust
//! Entities, zones, order of play, stats and the event log: the game's state.
|
|
//! The rules for resolving actions live in `queue.rs`.
|
|
|
|
use super::card::{Aura, Card, CardDef, CardLibrary};
|
|
use super::event::Event;
|
|
|
|
/// A handle to an entity. Ids are never reused and stay valid when the entity changes zone.
|
|
pub type EntityId = u32;
|
|
pub type PlayerId = u8;
|
|
|
|
/// Starting Health of each hero.
|
|
pub const HERO_HEALTH: i32 = 30;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Zone {
|
|
Deck,
|
|
Hand,
|
|
Board,
|
|
Graveyard,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Entity {
|
|
card: String,
|
|
owner: PlayerId,
|
|
zone: Zone,
|
|
/// Stamped each time the entity enters the board; `None` until it first does.
|
|
order_of_play: Option<u32>,
|
|
attack: i32,
|
|
/// Current Health. At 0 or below the entity is mortally wounded: still in play until the
|
|
/// Death Creation Step.
|
|
health: i32,
|
|
/// Hit by a destroy effect: still in play until the Death Creation Step.
|
|
pending_destroy: bool,
|
|
}
|
|
|
|
/// An aura in force: which player's minion has it.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct ActiveAura {
|
|
pub owner: PlayerId,
|
|
pub aura: Aura,
|
|
}
|
|
|
|
/// One player's zones. Each is an ordered list of ids (the board is left to right).
|
|
#[derive(Debug, Default)]
|
|
struct Side {
|
|
deck: Vec<EntityId>,
|
|
hand: Vec<EntityId>,
|
|
board: Vec<EntityId>,
|
|
graveyard: Vec<EntityId>,
|
|
hero: EntityId,
|
|
armor: i32,
|
|
}
|
|
|
|
impl Side {
|
|
fn zone(&self, zone: Zone) -> &Vec<EntityId> {
|
|
match zone {
|
|
Zone::Deck => &self.deck,
|
|
Zone::Hand => &self.hand,
|
|
Zone::Board => &self.board,
|
|
Zone::Graveyard => &self.graveyard,
|
|
}
|
|
}
|
|
|
|
fn zone_mut(&mut self, zone: Zone) -> &mut Vec<EntityId> {
|
|
match zone {
|
|
Zone::Deck => &mut self.deck,
|
|
Zone::Hand => &mut self.hand,
|
|
Zone::Board => &mut self.board,
|
|
Zone::Graveyard => &mut self.graveyard,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Makes a player's choices: called with what is being chosen and the options, it
|
|
/// returns one of the options. Hearthstone would ask the player or pick at random.
|
|
pub type Chooser = Box<dyn FnMut(&str, &[String]) -> String>;
|
|
|
|
pub struct World {
|
|
library: CardLibrary,
|
|
chooser: Chooser,
|
|
/// Indexed by `EntityId`.
|
|
entities: Vec<Entity>,
|
|
sides: [Side; 2],
|
|
next_order_of_play: u32,
|
|
log: Vec<Event>,
|
|
/// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`).
|
|
auras: Vec<ActiveAura>,
|
|
}
|
|
|
|
impl World {
|
|
/// A new game: both heroes are in play from the start, so they are the oldest entities.
|
|
pub fn new(library: CardLibrary) -> World {
|
|
let mut world = World {
|
|
library,
|
|
// Until a chooser is set, take the first option.
|
|
chooser: Box::new(|_, options| options[0].clone()),
|
|
entities: Vec::new(),
|
|
sides: Default::default(),
|
|
next_order_of_play: 0,
|
|
log: Vec::new(),
|
|
auras: Vec::new(),
|
|
};
|
|
for owner in 0..2 {
|
|
let id = world.entities.len() as EntityId;
|
|
world.next_order_of_play += 1;
|
|
world.entities.push(Entity {
|
|
card: "Hero".to_string(),
|
|
owner,
|
|
zone: Zone::Board,
|
|
order_of_play: Some(world.next_order_of_play),
|
|
attack: 0,
|
|
health: HERO_HEALTH,
|
|
pending_destroy: false,
|
|
});
|
|
// In play, but not on the board list: the board holds minions only.
|
|
world.sides[owner as usize].hero = id;
|
|
}
|
|
world
|
|
}
|
|
|
|
/// Create an entity of `card` for `owner` at the end of `zone` (the right end for the board).
|
|
pub fn create(&mut self, card: &str, owner: PlayerId, zone: Zone) -> EntityId {
|
|
self.create_at(card, owner, zone, None)
|
|
}
|
|
|
|
/// Create an entity of `card` for `owner` in `zone` at index `at` (clamped to the
|
|
/// zone's length), or at the end if `at` is `None`.
|
|
pub fn create_at(
|
|
&mut self,
|
|
card: &str,
|
|
owner: PlayerId,
|
|
zone: Zone,
|
|
at: Option<usize>,
|
|
) -> EntityId {
|
|
assert!(owner < 2, "no player {owner}");
|
|
let def = &self.library.get(card).def;
|
|
let entity = Entity {
|
|
card: def.name.clone(),
|
|
owner,
|
|
zone,
|
|
order_of_play: None,
|
|
attack: def.attack,
|
|
health: def.health,
|
|
pending_destroy: false,
|
|
};
|
|
let id = self.entities.len() as EntityId;
|
|
self.entities.push(entity);
|
|
self.enter(id, zone, at);
|
|
id
|
|
}
|
|
|
|
/// Move an entity to the end of `zone` on its owner's side, keeping its id.
|
|
pub fn move_to(&mut self, id: EntityId, zone: Zone) {
|
|
let (owner, from) = (self.entity(id).owner, self.entity(id).zone);
|
|
self.sides[owner as usize]
|
|
.zone_mut(from)
|
|
.retain(|&e| e != id);
|
|
self.enter(id, zone, None);
|
|
}
|
|
|
|
/// The single place an entity arrives in a zone. Entering the board starts a new order of play.
|
|
fn enter(&mut self, id: EntityId, zone: Zone, at: Option<usize>) {
|
|
let owner = self.entity(id).owner;
|
|
let list = self.sides[owner as usize].zone_mut(zone);
|
|
list.insert(at.map_or(list.len(), |i| i.min(list.len())), id);
|
|
let entity = &mut self.entities[id as usize];
|
|
entity.zone = zone;
|
|
if zone == Zone::Board {
|
|
self.next_order_of_play += 1;
|
|
entity.order_of_play = Some(self.next_order_of_play);
|
|
self.record(Event::Summoned { id });
|
|
}
|
|
}
|
|
|
|
fn entity(&self, id: EntityId) -> &Entity {
|
|
self.entities
|
|
.get(id as usize)
|
|
.unwrap_or_else(|| panic!("unknown entity id {id}"))
|
|
}
|
|
|
|
fn entity_mut(&mut self, id: EntityId) -> &mut Entity {
|
|
self.entities
|
|
.get_mut(id as usize)
|
|
.unwrap_or_else(|| panic!("unknown entity id {id}"))
|
|
}
|
|
|
|
// ---- Queries -------------------------------------------------------
|
|
|
|
pub fn card(&self, name: &str) -> &Card {
|
|
self.library.get(name)
|
|
}
|
|
|
|
pub fn has_card(&self, name: &str) -> bool {
|
|
self.library.contains(name)
|
|
}
|
|
|
|
/// The cards an Assemble may offer, sorted by name.
|
|
pub fn pool(&self) -> Vec<&Card> {
|
|
self.library.pool()
|
|
}
|
|
|
|
pub fn zone_of(&self, id: EntityId) -> Zone {
|
|
self.entity(id).zone
|
|
}
|
|
|
|
pub fn card_name(&self, id: EntityId) -> &str {
|
|
&self.entity(id).card
|
|
}
|
|
|
|
pub fn owner_of(&self, id: EntityId) -> PlayerId {
|
|
self.entity(id).owner
|
|
}
|
|
|
|
/// When the entity last entered the board (larger is later); `None` if it never has.
|
|
pub fn order_of_play(&self, id: EntityId) -> Option<u32> {
|
|
self.entity(id).order_of_play
|
|
}
|
|
|
|
/// The ids in one player's zone, in zone order (left to right for the board).
|
|
pub fn zone(&self, owner: PlayerId, zone: Zone) -> &[EntityId] {
|
|
assert!(owner < 2, "no player {owner}");
|
|
self.sides[owner as usize].zone(zone)
|
|
}
|
|
|
|
pub fn hero(&self, owner: PlayerId) -> EntityId {
|
|
assert!(owner < 2, "no player {owner}");
|
|
self.sides[owner as usize].hero
|
|
}
|
|
|
|
pub fn is_hero(&self, id: EntityId) -> bool {
|
|
self.sides.iter().any(|s| s.hero == id)
|
|
}
|
|
|
|
/// Every minion on either board, oldest first (order of play).
|
|
pub fn minions_in_play(&self) -> Vec<EntityId> {
|
|
let mut ids: Vec<EntityId> = self.sides.iter().flat_map(|s| s.board.clone()).collect();
|
|
ids.sort_by_key(|&id| self.order_of_play(id));
|
|
ids
|
|
}
|
|
|
|
pub fn attack(&self, id: EntityId) -> i32 {
|
|
self.entity(id).attack
|
|
}
|
|
|
|
pub fn health(&self, id: EntityId) -> i32 {
|
|
self.entity(id).health
|
|
}
|
|
|
|
/// The cost printed on the entity's card.
|
|
pub fn cost(&self, id: EntityId) -> i32 {
|
|
self.card(self.card_name(id)).def.cost
|
|
}
|
|
|
|
/// The minions in a player's hand, in hand order.
|
|
pub fn minions_in_hand(&self, owner: PlayerId) -> Vec<EntityId> {
|
|
self.zone(owner, Zone::Hand)
|
|
.iter()
|
|
.copied()
|
|
.filter(|&id| !self.card(self.card_name(id)).is_spell())
|
|
.collect()
|
|
}
|
|
|
|
/// Mortally wounded (0 or less Health) or pending destroy: it leaves play at the next
|
|
/// Death Creation Step.
|
|
pub fn is_doomed(&self, id: EntityId) -> bool {
|
|
let entity = self.entity(id);
|
|
entity.health <= 0 || entity.pending_destroy
|
|
}
|
|
|
|
/// Index of a minion on its owner's board, left to right; `None` if it is not on the board.
|
|
pub fn board_index(&self, id: EntityId) -> Option<usize> {
|
|
self.zone(self.owner_of(id), Zone::Board)
|
|
.iter()
|
|
.position(|&e| e == id)
|
|
}
|
|
|
|
pub fn armor(&self, owner: PlayerId) -> i32 {
|
|
self.sides[owner as usize].armor
|
|
}
|
|
|
|
pub fn active_auras(&self) -> &[ActiveAura] {
|
|
&self.auras
|
|
}
|
|
|
|
pub fn log(&self) -> &[Event] {
|
|
&self.log
|
|
}
|
|
|
|
// ---- Changes: each one records its event ----------------------------
|
|
|
|
pub fn record(&mut self, event: Event) {
|
|
self.log.push(event);
|
|
}
|
|
|
|
/// Add a card to the library, or replace the card with the same name.
|
|
pub fn register(&mut self, def: CardDef) {
|
|
self.library.register(def);
|
|
}
|
|
|
|
/// Add a card made by Assemble (see `assemble.rs`).
|
|
pub fn register_assembled(&mut self, def: CardDef) {
|
|
self.library.register_assembled(def);
|
|
}
|
|
|
|
pub fn set_chooser(&mut self, chooser: Chooser) {
|
|
self.chooser = chooser;
|
|
}
|
|
|
|
/// Ask the chooser to pick one of `options` (not empty). Panics if it answers
|
|
/// something that isn't an option.
|
|
pub fn choose(&mut self, kind: &str, options: &[String]) -> String {
|
|
let answer = (self.chooser)(kind, options);
|
|
assert!(
|
|
options.contains(&answer),
|
|
"chooser answered {answer:?} for {kind:?}; the options were {options:?}"
|
|
);
|
|
answer
|
|
}
|
|
|
|
/// Only an Aura Update calls this.
|
|
pub fn set_active_auras(&mut self, auras: Vec<ActiveAura>) {
|
|
self.auras = auras;
|
|
}
|
|
|
|
pub fn clear_log(&mut self) {
|
|
self.log.clear();
|
|
}
|
|
|
|
pub fn damage(&mut self, target: EntityId, amount: i32) {
|
|
self.entity_mut(target).health -= amount;
|
|
self.record(Event::Damaged { target, amount });
|
|
}
|
|
|
|
/// Draw the top card of `player`'s deck (the first in the list). An empty deck
|
|
/// records `Fatigue` and changes nothing else.
|
|
pub fn draw(&mut self, player: PlayerId) {
|
|
match self.zone(player, Zone::Deck).first() {
|
|
Some(&id) => {
|
|
self.move_to(id, Zone::Hand);
|
|
self.record(Event::Drew { player, id });
|
|
}
|
|
None => self.record(Event::Fatigue { player }),
|
|
}
|
|
}
|
|
|
|
/// Mark for destruction. Nothing leaves play until the Death Creation Step.
|
|
pub fn mark_destroyed(&mut self, id: EntityId) {
|
|
self.entity_mut(id).pending_destroy = true;
|
|
}
|
|
|
|
/// Move a minion from play to its owner's graveyard, recording its Death Event.
|
|
pub fn kill(&mut self, id: EntityId, position: usize) {
|
|
self.entity_mut(id).pending_destroy = false;
|
|
self.move_to(id, Zone::Graveyard);
|
|
self.record(Event::Destroyed { id, position });
|
|
}
|
|
|
|
pub fn gain_armor(&mut self, player: PlayerId, amount: i32) {
|
|
self.sides[player as usize].armor += amount;
|
|
self.record(Event::ArmorGained { player, amount });
|
|
}
|
|
|
|
pub fn buff(&mut self, id: EntityId, attack: i32, health: i32) {
|
|
let entity = self.entity_mut(id);
|
|
entity.attack += attack;
|
|
entity.health += health;
|
|
let (attack, health) = (entity.attack, entity.health);
|
|
self.record(Event::StatsChanged { id, attack, health });
|
|
}
|
|
|
|
pub fn gain_attack(&mut self, id: EntityId, amount: i32) {
|
|
let entity = self.entity_mut(id);
|
|
entity.attack += amount;
|
|
let (attack, health) = (entity.attack, entity.health);
|
|
self.record(Event::StatsChanged { id, attack, health });
|
|
}
|
|
}
|