diff --git a/README.md b/README.md index 69f50e3..d30a34b 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,18 @@ character library in the browser. - Ability scores, skills, armor class, hit points, initiative, passive Perception, spell preparation, and spell save calculations - Gold, inventory quantities, equipped items, and tool proficiencies +- Play-state tracking for spell slots, limited-use class resources, short and + long rests, Hit Dice, death saves, Inspiration, Concentration, Exhaustion, + and conditions +- Consumable quantities, item charges, attunement, and configurable custom + resources - Printable character sheet - Versioned JSON character import and export - End-to-end encrypted Iroh table sessions: players share their sheets and can inspect the other connected characters; the Game Master gets a complete read-only sheet for each player + including current hit points, spell slots, Hit Dice, class resources, + conditions, and other play-state counters Rules data is supplied at runtime and is not configured in the application build. diff --git a/assets/styles.css b/assets/styles.css index f08a2e0..2edd81e 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -797,6 +797,8 @@ button.data-status:hover { background: var(--surface-soft); } .remote-card div > span { margin-top: 2px; color: var(--muted); font-size: 8px; } .remote-vitals { display: flex; gap: 5px; } .remote-vitals span { padding: 3px 5px; color: var(--ink); background: #111411; font: 8px var(--font-mono); } +.remote-resource-summary { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 4px; } +.remote-resource-summary span { max-width: 100%; padding: 3px 5px; color: var(--muted); border: 1px solid var(--line); background: #111411; font: 8px var(--font-mono); } .remote-card { width: 100%; color: var(--ink); @@ -1566,6 +1568,76 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .dice-window-controls { grid-template-columns: 1fr; } .dice-chat-heading { align-items: stretch; flex-direction: column; } } +.play-resources { + margin: 16px 0 18px; + padding: 14px; + border: 1px solid var(--line); + background: linear-gradient(135deg, rgba(201,255,82,.05), rgba(255,255,255,.015)); +} +.play-resources-heading, .rest-actions, .status-actions, .condition-controls, .rest-dialog-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.play-resources-heading { justify-content: space-between; margin-bottom: 12px; } +.play-resources-heading h3, .play-resource-group h4 { margin: 2px 0 0; } +.play-resource-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; } +.play-resource-group { min-width: 0; padding: 12px; border: 1px solid var(--line); background: var(--surface-raised); } +.counter-grid { display: grid; gap: 6px; margin-top: 9px; } +.resource-counter { min-width: 0; display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 7px; border: 1px solid var(--line); } +.resource-counter-label { min-width: 0; } +.resource-counter-label strong, .resource-counter-label small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.resource-counter-label small, .muted-copy, .effect-note { color: var(--muted); font-size: 10px; } +.resource-counter-controls, .item-resource-controls { display: flex; align-items: center; gap: 5px; } +.resource-counter-controls button, .item-resource-controls button, .condition-chip button, .status-chip button { + min-width: 28px; + height: 28px; + padding: 0 7px; + border: 1px solid var(--line); + color: var(--ink); + background: #111; +} +.resource-counter-controls button:disabled, .item-resource-controls button:disabled { opacity: .35; } +.counter-delete { color: var(--danger) !important; } +.custom-resource-form { display: grid; grid-template-columns: minmax(0,1fr) 62px minmax(100px,.7fr) auto; gap: 6px; margin-top: 10px; } +.custom-resource-form input, .custom-resource-form select, .condition-controls select, .condition-controls input, .item-resource-setup input { + min-width: 0; + height: 34px; + border: 1px solid var(--line); + color: var(--ink); + background: #111; +} +.status-actions { margin: 9px 0; } +.status-toggle, .status-chip, .condition-chip { + min-height: 32px; + padding: 6px 9px; + border: 1px solid var(--line); + color: var(--muted); + background: #111; +} +.status-toggle.active, .status-chip.active { border-color: var(--accent); color: var(--ink); background: var(--accent-soft); } +.status-chip, .condition-chip { display: inline-flex; align-items: center; gap: 6px; } +.condition-list { display: flex; flex-wrap: wrap; gap: 5px; margin: 8px 0; } +.condition-controls { align-items: end; } +.exhaustion-control { display: grid; gap: 3px; color: var(--muted); font-size: 9px; } +.exhaustion-control input { width: 58px; padding: 0 7px; } +.death-saves { margin: 10px 0; padding: 9px; border: 1px solid rgba(255,92,92,.4); background: rgba(255,92,92,.06); } +.death-saves > div:first-child { display: flex; justify-content: space-between; gap: 8px; } +.death-save-actions { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 8px; } +.death-save-actions button { min-height: 30px; border: 1px solid var(--line); color: var(--ink); background: #111; } +.resource-dialog { z-index: 140; } +.resource-dialog .health-choice-card { width: min(520px,100%); } +.cast-slot-options, .rest-summary { display: grid; gap: 7px; margin: 12px 0; } +.rest-dialog-actions { justify-content: flex-end; margin-top: 14px; } +.spell-cast-button { min-height: 32px; padding: 0 12px; border: 1px solid var(--accent); color: var(--ink); background: var(--accent-soft); } +.spell-row .spell-cast-button { align-self: center; } +.equipment-row { grid-template-columns: minmax(0,1fr) 64px minmax(130px,auto) 104px; } +.item-resource-setup { display: flex; align-items: center; gap: 5px; } +.item-resource-setup input { width: 52px; padding: 0 6px; } +.item-resource-controls { color: var(--muted); font-size: 9px; white-space: nowrap; } +.attunement-toggle.active { border-color: var(--accent); color: var(--accent); } +.equipment-use-button { min-width: 42px; height: 32px; border: 1px solid var(--accent); color: var(--ink); background: var(--accent-soft); } @media (min-width: 801px) { .master-inspector:not(.empty-inspector) { display: grid; @@ -1620,6 +1692,11 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .custom-companion-fields { grid-template-columns: repeat(2,minmax(0,1fr)); } .custom-companion-fields .secondary-button { grid-column: 1 / -1; } .companion-card-list { grid-template-columns: 1fr; } + .play-resource-grid { grid-template-columns: 1fr; } + .equipment-row { grid-template-columns: minmax(0,1fr) 64px; } + .item-resource-setup, .item-resource-controls { grid-column: 1 / -1; } + .equipment-actions { grid-column: 1 / -1; justify-content: flex-start; } + .spell-row .spell-cast-button { grid-column: 3; } } @media (max-width: 560px) { .panel { padding: 22px 16px 24px; } @@ -1665,6 +1742,13 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .companion-catalog-controls, .custom-companion-fields, .companion-preview-stats { grid-template-columns: 1fr; } .companion-preview-stats span + span { border-left: 0; border-top: 1px solid var(--line); } .companion-add-button, .custom-companion-fields .secondary-button { grid-column: auto; width: 100%; } + .play-resources { padding: 10px; } + .play-resources-heading { align-items: flex-start; flex-direction: column; } + .custom-resource-form { grid-template-columns: 1fr; } + .resource-counter { grid-template-columns: 1fr; } + .resource-counter-controls { justify-content: space-between; } + .condition-controls { align-items: stretch; flex-direction: column; } + .exhaustion-control input { width: 100%; } } @media print { diff --git a/src/sheets.gleam b/src/sheets.gleam index c35a9d9..b2f80ec 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -30,10 +30,11 @@ import sheets/peer.{ type CompanionInitiative, type DiceRoll, type Encounter, type InitiativeEntry, type PlayerInitiative, type Snapshot, type SnapshotAbilities, type SnapshotChoice, type SnapshotFeat, type SnapshotFeature, - type SnapshotSkill, type SnapshotSpell, CompanionInitiative, DiceRoll, - Encounter, InitiativeEntry, PlayerInitiative, RuleRestrictions, Snapshot, - SnapshotAbilities, SnapshotChoice, SnapshotEquipment, SnapshotFeat, - SnapshotFeature, SnapshotSkill, SnapshotSpell, + type SnapshotResource, type SnapshotSkill, type SnapshotSpell, + CompanionInitiative, DiceRoll, Encounter, InitiativeEntry, PlayerInitiative, + RuleRestrictions, Snapshot, SnapshotAbilities, SnapshotChoice, + SnapshotEquipment, SnapshotFeat, SnapshotFeature, SnapshotResource, + SnapshotSkill, SnapshotSpell, } import varasto @@ -182,6 +183,65 @@ pub type HealthIncrease { HealthIncrease(level: Int, die_value: Int, rolled: Bool) } +pub type RecoveryRule { + ShortRestAll + ShortRestAmount(Int) + LongRestAll + ManualRecovery +} + +pub type ResourceUse { + ResourceUse(id: String, spent: Int) +} + +pub type CustomResource { + CustomResource( + id: String, + name: String, + maximum: Int, + spent: Int, + recovery: RecoveryRule, + ) +} + +pub type SpellSlotUse { + SpellSlotUse(level: Int, spent: Int) +} + +pub type CharacterCondition { + CharacterCondition(name: String, value: Int) +} + +pub type ItemResource { + ItemResource( + name: String, + maximum_charges: Int, + spent_charges: Int, + attuned: Bool, + ) +} + +pub type RestKind { + NoRest + ShortRest + LongRest +} + +pub type RestUndo { + RestUndo( + current_hit_points: Int, + temporary_hit_points: Int, + hit_dice_spent: Int, + spell_slot_uses: List(SpellSlotUse), + resource_uses: List(ResourceUse), + custom_resources: List(CustomResource), + conditions: List(CharacterCondition), + concentration: String, + death_save_successes: Int, + death_save_failures: Int, + ) +} + pub type Coin { Platinum Gold @@ -222,6 +282,15 @@ pub type StoredCharacter { notes: String, other_proficiencies: List(String), health_increases: List(HealthIncrease), + spell_slot_uses: List(SpellSlotUse), + resource_uses: List(ResourceUse), + custom_resources: List(CustomResource), + death_save_successes: Int, + death_save_failures: Int, + inspiration: Bool, + concentration: String, + conditions: List(CharacterCondition), + item_resources: List(ItemResource), ) } @@ -289,6 +358,26 @@ pub type Model { notes: String, other_proficiencies: List(String), health_increases: List(HealthIncrease), + spell_slot_uses: List(SpellSlotUse), + resource_uses: List(ResourceUse), + custom_resources: List(CustomResource), + death_save_successes: Int, + death_save_failures: Int, + inspiration: Bool, + concentration: String, + conditions: List(CharacterCondition), + item_resources: List(ItemResource), + custom_resource_name: String, + custom_resource_maximum: String, + custom_resource_recovery: String, + pending_spell_name: String, + pending_spell_level: Int, + rest_kind: RestKind, + rest_hit_dice_spent: Int, + rest_hit_points_recovered: Int, + rest_undo: Result(RestUndo, Nil), + condition_choice: String, + item_charge_maximum: String, equipment_catalog: List(InventoryItem), equipment_choice: String, equipment_search: String, @@ -529,6 +618,40 @@ pub type Message { UserSelectedFeat(Int, String) UserSelectedFeatAbility(Int, String) UserToggledSpell(String, Int) + UserOpenedSpellCast(String, Int) + UserCancelledSpellCast + UserConfirmedSpellCast(Int) + UserSpentSpellSlot(Int) + UserRestoredSpellSlot(Int) + UserSpentResource(String) + UserRestoredResource(String) + UserChangedCustomResourceName(String) + UserChangedCustomResourceMaximum(String) + UserChangedCustomResourceRecovery(String) + UserAddedCustomResource + UserRemovedCustomResource(String) + UserOpenedRest(RestKind) + UserSpentRestHitDie + UserCancelledRest + UserCompletedRest + UserUndidRest + UserRecordedDeathSaveSuccess + UserRecordedDeathSaveFailure(Int) + UserRecordedDeathSaveNaturalTwenty + UserResetDeathSaves + UserToggledInspiration + UserSpentInspirationOnRoll + UserClearedConcentration + UserChangedConditionChoice(String) + UserAddedCondition + UserRemovedCondition(String) + UserChangedExhaustion(String) + UserUsedEquipment(String) + UserChangedItemChargeMaximum(String) + UserConfiguredItemCharges(String) + UserSpentItemCharge(String) + UserRestoredItemCharge(String) + UserToggledItemAttunement(String) UserChangedSpellSearch(String) UserChangedSpellLevelFilter(String) UserChangedSpellPreparationFilter(String) @@ -546,6 +669,17 @@ type ClassChoice { ClassChoice(name: String, source: String, hit_die: Int, role: String) } +type ResourceDefinition { + ResourceDefinition( + id: String, + name: String, + maximum: Int, + recovery: RecoveryRule, + source: String, + die_sides: Int, + ) +} + type Skill { Skill(name: String, ability: Ability) } @@ -611,6 +745,26 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { notes: "", other_proficiencies: [], health_increases: [], + spell_slot_uses: [], + resource_uses: [], + custom_resources: [], + death_save_successes: 0, + death_save_failures: 0, + inspiration: False, + concentration: "", + conditions: [], + item_resources: [], + custom_resource_name: "", + custom_resource_maximum: "1", + custom_resource_recovery: "long", + pending_spell_name: "", + pending_spell_level: 0, + rest_kind: NoRest, + rest_hit_dice_spent: 0, + rest_hit_points_recovered: 0, + rest_undo: Error(Nil), + condition_choice: "", + item_charge_maximum: "1", equipment_catalog: [], equipment_choice: "", equipment_search: "", @@ -974,6 +1128,15 @@ fn new_stored_character() -> StoredCharacter { notes: "", other_proficiencies: [], health_increases: [], + spell_slot_uses: [], + resource_uses: [], + custom_resources: [], + death_save_successes: 0, + death_save_failures: 0, + inspiration: False, + concentration: "", + conditions: [], + item_resources: [], ) } @@ -1491,6 +1654,9 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { feature_selections: [], health_increases: [], spell_level_filter: AllSpellLevels, + spell_slot_uses: [], + resource_uses: [], + concentration: "", skills: remove_selected_class_skills( model.skills, model.feature_selections, @@ -1509,7 +1675,14 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ) } UserChangedSubclass(name) -> { - let updated = Model(..model, subclass_name: name, spells: []) + let updated = + Model( + ..model, + subclass_name: name, + spells: [], + spell_slot_uses: [], + concentration: "", + ) let #(updated, persistence) = update_and_sync(model, updated) #( updated, @@ -1587,6 +1760,7 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ..model, dice_open: True, dice_note: label, + dice_roll_mode: default_d20_roll_mode(model, label), dice_modifier: int.to_string(modifier), dice_count: "1", dice_sides: 20, @@ -1885,19 +2059,25 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ) } } - UserChangedCurrentHitPoints(value) -> + UserChangedCurrentHitPoints(value) -> { + let current_hit_points = + parse_bounded(value, effective_current_hit_points(model), 0, 999_999) update_and_sync( model, Model( ..model, - current_hit_points: parse_bounded( - value, - effective_current_hit_points(model), - 0, - 999_999, - ), + current_hit_points:, + death_save_successes: case current_hit_points > 0 { + True -> 0 + False -> model.death_save_successes + }, + death_save_failures: case current_hit_points > 0 { + True -> 0 + False -> model.death_save_failures + }, ), ) + } UserChangedTemporaryHitPoints(value) -> update_and_sync( model, @@ -2017,6 +2197,9 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { Model( ..model, equipment: list.filter(model.equipment, fn(item) { item.name != name }), + item_resources: list.filter(model.item_resources, fn(resource) { + resource.name != name + }), ), ) UserSelectedFeat(level, name) -> { @@ -2042,6 +2225,403 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { } update_and_sync(model, Model(..model, spells:)) } + UserOpenedSpellCast(name, level) -> #( + Model(..model, pending_spell_name: name, pending_spell_level: level), + effect.none(), + ) + UserCancelledSpellCast -> #( + Model(..model, pending_spell_name: "", pending_spell_level: 0), + effect.none(), + ) + UserConfirmedSpellCast(level) -> { + let spell_slot_uses = case level { + 0 -> model.spell_slot_uses + _ -> + change_spell_slot_use( + model.spell_slot_uses, + level, + 1, + spell_slot_maximum(model, level), + ) + } + let concentration = case + list.find(model.spell_catalog, fn(spell) { + spell.name == model.pending_spell_name + }) + { + Ok(spell) -> + case string.contains(spell.duration, "Concentration") { + True -> spell.name + False -> model.concentration + } + Error(Nil) -> model.concentration + } + update_and_sync( + model, + Model( + ..model, + spell_slot_uses:, + concentration:, + pending_spell_name: "", + pending_spell_level: 0, + ), + ) + } + UserSpentSpellSlot(level) -> + update_and_sync( + model, + Model( + ..model, + spell_slot_uses: change_spell_slot_use( + model.spell_slot_uses, + level, + 1, + spell_slot_maximum(model, level), + ), + ), + ) + UserRestoredSpellSlot(level) -> + update_and_sync( + model, + Model( + ..model, + spell_slot_uses: change_spell_slot_use( + model.spell_slot_uses, + level, + -1, + spell_slot_maximum(model, level), + ), + ), + ) + UserSpentResource(id) -> + case resource_definition(model, id) { + Error(Nil) -> + update_and_sync( + model, + Model( + ..model, + custom_resources: change_custom_resource( + model.custom_resources, + id, + 1, + ), + ), + ) + Ok(resource) -> + update_and_sync( + model, + Model( + ..model, + resource_uses: change_resource_use( + model.resource_uses, + id, + 1, + resource.maximum, + ), + ), + ) + } + UserRestoredResource(id) -> + case resource_definition(model, id) { + Error(Nil) -> + update_and_sync( + model, + Model( + ..model, + custom_resources: change_custom_resource( + model.custom_resources, + id, + -1, + ), + ), + ) + Ok(resource) -> + update_and_sync( + model, + Model( + ..model, + resource_uses: change_resource_use( + model.resource_uses, + id, + -1, + resource.maximum, + ), + ), + ) + } + UserChangedCustomResourceName(value) -> #( + Model(..model, custom_resource_name: value), + effect.none(), + ) + UserChangedCustomResourceMaximum(value) -> #( + Model(..model, custom_resource_maximum: value), + effect.none(), + ) + UserChangedCustomResourceRecovery(value) -> #( + Model(..model, custom_resource_recovery: value), + effect.none(), + ) + UserAddedCustomResource -> { + let name = string.trim(model.custom_resource_name) + let maximum = parse_bounded(model.custom_resource_maximum, 1, 1, 999) + case name { + "" -> #(model, effect.none()) + _ -> + update_and_sync( + model, + Model( + ..model, + custom_resources: [ + CustomResource( + id: browser.new_id(), + name:, + maximum:, + spent: 0, + recovery: recovery_rule_from_name( + model.custom_resource_recovery, + ), + ), + ..model.custom_resources + ], + custom_resource_name: "", + custom_resource_maximum: "1", + ), + ) + } + } + UserRemovedCustomResource(id) -> + update_and_sync( + model, + Model( + ..model, + custom_resources: list.filter(model.custom_resources, fn(resource) { + resource.id != id + }), + ), + ) + UserOpenedRest(kind) -> #( + Model( + ..model, + rest_kind: kind, + rest_hit_dice_spent: 0, + rest_hit_points_recovered: 0, + ), + effect.none(), + ) + UserSpentRestHitDie -> + case + model.rest_kind == ShortRest, + model.hit_dice_spent + model.rest_hit_dice_spent < model.level, + effective_current_hit_points(model) + model.rest_hit_points_recovered + < character_hit_points(model) + { + True, True, True -> { + let #(rolled, dice_seed) = + random.step( + random.int(1, class_hit_die(model.class_name)), + model.dice_seed, + ) + let recovery = + int.max( + 1, + rolled + modifier(effective_ability_score(model, Constitution)), + ) + #( + Model( + ..model, + rest_hit_dice_spent: model.rest_hit_dice_spent + 1, + rest_hit_points_recovered: model.rest_hit_points_recovered + + recovery, + dice_seed:, + ), + effect.none(), + ) + } + _, _, _ -> #(model, effect.none()) + } + UserCancelledRest -> #( + Model( + ..model, + rest_kind: NoRest, + rest_hit_dice_spent: 0, + rest_hit_points_recovered: 0, + ), + effect.none(), + ) + UserCompletedRest -> complete_rest(model) + UserUndidRest -> undo_rest(model) + UserRecordedDeathSaveSuccess -> + update_and_sync( + model, + Model( + ..model, + death_save_successes: int.min(3, model.death_save_successes + 1), + ), + ) + UserRecordedDeathSaveFailure(amount) -> + update_and_sync( + model, + Model( + ..model, + death_save_failures: int.min(3, model.death_save_failures + amount), + ), + ) + UserRecordedDeathSaveNaturalTwenty -> + update_and_sync( + model, + Model( + ..model, + current_hit_points: 1, + death_save_successes: 0, + death_save_failures: 0, + ), + ) + UserResetDeathSaves -> + update_and_sync( + model, + Model(..model, death_save_successes: 0, death_save_failures: 0), + ) + UserToggledInspiration -> + update_and_sync(model, Model(..model, inspiration: !model.inspiration)) + UserSpentInspirationOnRoll -> + case model.inspiration { + False -> #(model, effect.none()) + True -> + update_and_sync( + model, + Model( + ..model, + inspiration: False, + dice_roll_mode: case model.dice_roll_mode { + DisadvantageRoll -> NormalRoll + NormalRoll -> AdvantageRoll + AdvantageRoll -> AdvantageRoll + }, + ), + ) + } + UserClearedConcentration -> + update_and_sync(model, Model(..model, concentration: "")) + UserChangedConditionChoice(value) -> #( + Model(..model, condition_choice: value), + effect.none(), + ) + UserAddedCondition -> { + let name = model.condition_choice + case + name == "" + || list.any(model.conditions, fn(condition) { condition.name == name }) + { + True -> #(model, effect.none()) + False -> + update_and_sync( + model, + Model( + ..model, + conditions: [ + CharacterCondition(name:, value: 0), + ..model.conditions + ], + condition_choice: "", + ), + ) + } + } + UserRemovedCondition(name) -> + update_and_sync( + model, + Model( + ..model, + conditions: list.filter(model.conditions, fn(condition) { + condition.name != name + }), + ), + ) + UserChangedExhaustion(value) -> { + let exhaustion = parse_bounded(value, exhaustion_level(model), 0, 6) + update_and_sync( + model, + Model( + ..model, + conditions: set_condition_value( + model.conditions, + "Exhaustion", + exhaustion, + ), + ), + ) + } + UserUsedEquipment(name) -> { + case + list.any(model.item_resources, fn(resource) { resource.name == name }) + { + True -> + update_and_sync( + model, + Model( + ..model, + item_resources: change_item_charge(model.item_resources, name, 1), + ), + ) + False -> { + let equipment = + list.map(model.equipment, fn(item) { + case item.name == name && item.quantity > 0 { + True -> EquipmentItem(..item, quantity: item.quantity - 1) + False -> item + } + }) + update_and_sync(model, Model(..model, equipment:)) + } + } + } + UserChangedItemChargeMaximum(value) -> #( + Model(..model, item_charge_maximum: value), + effect.none(), + ) + UserConfiguredItemCharges(name) -> { + let maximum = parse_bounded(model.item_charge_maximum, 1, 1, 999) + update_and_sync( + model, + Model( + ..model, + item_resources: upsert_item_resource( + model.item_resources, + ItemResource( + name:, + maximum_charges: maximum, + spent_charges: 0, + attuned: False, + ), + ), + item_charge_maximum: "1", + ), + ) + } + UserSpentItemCharge(name) -> + update_and_sync( + model, + Model( + ..model, + item_resources: change_item_charge(model.item_resources, name, 1), + ), + ) + UserRestoredItemCharge(name) -> + update_and_sync( + model, + Model( + ..model, + item_resources: change_item_charge(model.item_resources, name, -1), + ), + ) + UserToggledItemAttunement(name) -> + update_and_sync( + model, + Model( + ..model, + item_resources: toggle_item_attunement(model.item_resources, name), + ), + ) UserChangedSpellSearch(value) -> #( Model(..model, spell_search: value), effect.none(), @@ -2629,20 +3209,646 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { #( Model( ..model, - saved_player_characters:, - inspected_saved_player_character_id: case - model.inspected_saved_player_character_id == id - { - True -> "" - False -> model.inspected_saved_player_character_id + saved_player_characters:, + inspected_saved_player_character_id: case + model.inspected_saved_player_character_id == id + { + True -> "" + False -> model.inspected_saved_player_character_id + }, + ), + persist_saved_player_characters(saved_player_characters), + ) + } + } +} + +fn change_spell_slot_use( + uses: List(SpellSlotUse), + level: Int, + delta: Int, + maximum: Int, +) -> List(SpellSlotUse) { + let current = case list.find(uses, fn(slot) { slot.level == level }) { + Ok(slot) -> slot.spent + Error(Nil) -> 0 + } + let spent = current + delta |> int.max(0) |> int.min(maximum) + case spent { + 0 -> list.filter(uses, fn(slot) { slot.level != level }) + _ -> [ + SpellSlotUse(level:, spent:), + ..list.filter(uses, fn(slot) { slot.level != level }) + ] + } +} + +fn spell_slot_spent(model: Model, level: Int) -> Int { + case list.find(model.spell_slot_uses, fn(slot) { slot.level == level }) { + Ok(slot) -> slot.spent + Error(Nil) -> 0 + } +} + +fn spell_slot_maximum(model: Model, slot_level: Int) -> Int { + let table = case model.class_name { + "Bard" -> full_caster_slots() + "Cleric" -> full_caster_slots() + "Druid" -> full_caster_slots() + "Sorcerer" -> full_caster_slots() + "Wizard" -> full_caster_slots() + "Artificer" -> half_caster_slots() + "Paladin" -> half_caster_slots() + "Ranger" -> half_caster_slots() + "Warlock" -> warlock_slots() + "Fighter" if model.subclass_name == "Eldritch Knight" -> + third_caster_slots() + "Rogue" if model.subclass_name == "Arcane Trickster" -> third_caster_slots() + _ -> [] + } + case list_item(table, model.level - 1) { + Error(Nil) -> 0 + Ok(row) -> list_item(row, slot_level - 1) |> result.unwrap(0) + } +} + +fn list_item(items: List(a), index: Int) -> Result(a, Nil) { + case items, index { + [], _ -> Error(Nil) + [item, ..], 0 -> Ok(item) + [_, ..rest], index if index > 0 -> list_item(rest, index - 1) + [_, ..], _ -> Error(Nil) + } +} + +fn available_spell_slot_levels(model: Model, minimum_level: Int) -> List(Int) { + [1, 2, 3, 4, 5, 6, 7, 8, 9] + |> list.filter(fn(level) { + level >= minimum_level + && spell_slot_spent(model, level) < spell_slot_maximum(model, level) + }) +} + +fn full_caster_slots() -> List(List(Int)) { + [ + [2], + [3], + [4, 2], + [4, 3], + [4, 3, 2], + [4, 3, 3], + [4, 3, 3, 1], + [4, 3, 3, 2], + [4, 3, 3, 3, 1], + [4, 3, 3, 3, 2], + [4, 3, 3, 3, 2, 1], + [4, 3, 3, 3, 2, 1], + [4, 3, 3, 3, 2, 1, 1], + [4, 3, 3, 3, 2, 1, 1], + [4, 3, 3, 3, 2, 1, 1, 1], + [4, 3, 3, 3, 2, 1, 1, 1], + [4, 3, 3, 3, 2, 1, 1, 1, 1], + [4, 3, 3, 3, 3, 1, 1, 1, 1], + [4, 3, 3, 3, 3, 2, 1, 1, 1], + [4, 3, 3, 3, 3, 2, 2, 1, 1], + ] +} + +fn half_caster_slots() -> List(List(Int)) { + [ + [2], + [2], + [3], + [3], + [4, 2], + [4, 2], + [4, 3], + [4, 3], + [4, 3, 2], + [4, 3, 2], + [4, 3, 3], + [4, 3, 3], + [4, 3, 3, 1], + [4, 3, 3, 1], + [4, 3, 3, 2], + [4, 3, 3, 2], + [4, 3, 3, 3, 1], + [4, 3, 3, 3, 1], + [4, 3, 3, 3, 2], + [4, 3, 3, 3, 2], + ] +} + +fn third_caster_slots() -> List(List(Int)) { + [ + [], + [], + [2], + [3], + [3], + [3], + [4, 2], + [4, 2], + [4, 2], + [4, 3], + [4, 3], + [4, 3], + [4, 3, 2], + [4, 3, 2], + [4, 3, 2], + [4, 3, 3], + [4, 3, 3], + [4, 3, 3], + [4, 3, 3, 1], + [4, 3, 3, 1], + ] +} + +fn warlock_slots() -> List(List(Int)) { + [ + [1], + [2], + [0, 2], + [0, 2], + [0, 0, 2], + [0, 0, 2], + [0, 0, 0, 2], + [0, 0, 0, 2], + [0, 0, 0, 0, 2], + [0, 0, 0, 0, 2], + [0, 0, 0, 0, 3], + [0, 0, 0, 0, 3], + [0, 0, 0, 0, 3], + [0, 0, 0, 0, 3], + [0, 0, 0, 0, 3], + [0, 0, 0, 0, 3], + [0, 0, 0, 0, 4], + [0, 0, 0, 0, 4], + [0, 0, 0, 0, 4], + [0, 0, 0, 0, 4], + ] +} + +fn class_resource_definitions(model: Model) -> List(ResourceDefinition) { + let level = model.level + case model.class_name { + "Barbarian" -> [ + ResourceDefinition( + id: "barbarian:rage", + name: "Rage", + maximum: case level { + level if level >= 17 -> 6 + level if level >= 12 -> 5 + level if level >= 6 -> 4 + level if level >= 3 -> 3 + _ -> 2 + }, + recovery: ShortRestAmount(1), + source: "Barbarian", + die_sides: 0, + ), + ] + "Bard" -> [ + ResourceDefinition( + id: "bard:inspiration", + name: "Bardic Inspiration", + maximum: int.max(1, modifier(effective_ability_score(model, Charisma))), + recovery: case level >= 5 { + True -> ShortRestAll + False -> LongRestAll + }, + source: "Bard", + die_sides: case level { + level if level >= 15 -> 12 + level if level >= 10 -> 10 + level if level >= 5 -> 8 + _ -> 6 + }, + ), + ] + "Cleric" if level >= 2 -> [ + ResourceDefinition( + id: "cleric:channel-divinity", + name: "Channel Divinity", + maximum: case level { + level if level >= 18 -> 4 + level if level >= 6 -> 3 + _ -> 2 + }, + recovery: ShortRestAmount(1), + source: "Cleric", + die_sides: 0, + ), + ] + "Druid" if level >= 2 -> [ + ResourceDefinition( + id: "druid:wild-shape", + name: "Wild Shape", + maximum: case level { + level if level >= 17 -> 4 + level if level >= 6 -> 3 + _ -> 2 + }, + recovery: ShortRestAmount(1), + source: "Druid", + die_sides: 0, + ), + ] + "Fighter" -> [ + ResourceDefinition( + id: "fighter:second-wind", + name: "Second Wind", + maximum: case level { + level if level >= 10 -> 4 + level if level >= 4 -> 3 + _ -> 2 + }, + recovery: ShortRestAmount(1), + source: "Fighter", + die_sides: 10, + ), + ..case level >= 2 { + True -> [ + ResourceDefinition( + id: "fighter:action-surge", + name: "Action Surge", + maximum: case level >= 17 { + True -> 2 + False -> 1 + }, + recovery: ShortRestAll, + source: "Fighter", + die_sides: 0, + ), + ] + False -> [] + } + ] + "Monk" if level >= 2 -> [ + ResourceDefinition( + id: "monk:focus", + name: "Focus Points", + maximum: level, + recovery: ShortRestAll, + source: "Monk", + die_sides: 0, + ), + ] + "Paladin" if level >= 3 -> [ + ResourceDefinition( + id: "paladin:channel-divinity", + name: "Channel Divinity", + maximum: case level >= 11 { + True -> 3 + False -> 2 + }, + recovery: ShortRestAmount(1), + source: "Paladin", + die_sides: 0, + ), + ] + "Ranger" -> [ + ResourceDefinition( + id: "ranger:favored-enemy", + name: "Favored Enemy", + maximum: case level { + level if level >= 17 -> 6 + level if level >= 13 -> 5 + level if level >= 9 -> 4 + level if level >= 5 -> 3 + _ -> 2 + }, + recovery: LongRestAll, + source: "Ranger", + die_sides: 0, + ), + ] + "Sorcerer" if level >= 2 -> [ + ResourceDefinition( + id: "sorcerer:sorcery-points", + name: "Sorcery Points", + maximum: level, + recovery: LongRestAll, + source: "Sorcerer", + die_sides: 0, + ), + ] + "Warlock" if level >= 2 -> [ + ResourceDefinition( + id: "warlock:magical-cunning", + name: "Magical Cunning", + maximum: 1, + recovery: LongRestAll, + source: "Warlock", + die_sides: 0, + ), + ] + "Wizard" -> [ + ResourceDefinition( + id: "wizard:arcane-recovery", + name: "Arcane Recovery", + maximum: 1, + recovery: LongRestAll, + source: "Wizard", + die_sides: 0, + ), + ] + _ -> [] + } +} + +fn resource_definition( + model: Model, + id: String, +) -> Result(ResourceDefinition, Nil) { + list.find(class_resource_definitions(model), fn(resource) { + resource.id == id + }) +} + +fn resource_spent(model: Model, id: String) -> Int { + case list.find(model.resource_uses, fn(resource) { resource.id == id }) { + Ok(resource) -> resource.spent + Error(Nil) -> 0 + } +} + +fn change_resource_use( + uses: List(ResourceUse), + id: String, + delta: Int, + maximum: Int, +) -> List(ResourceUse) { + let current = case list.find(uses, fn(resource) { resource.id == id }) { + Ok(resource) -> resource.spent + Error(Nil) -> 0 + } + let spent = current + delta |> int.max(0) |> int.min(maximum) + case spent { + 0 -> list.filter(uses, fn(resource) { resource.id != id }) + _ -> [ + ResourceUse(id:, spent:), + ..list.filter(uses, fn(resource) { resource.id != id }) + ] + } +} + +fn recover_resource_uses(model: Model, kind: RestKind) -> List(ResourceUse) { + list.fold( + class_resource_definitions(model), + model.resource_uses, + fn(uses, definition) { + let recovered = case kind, definition.recovery { + LongRest, ManualRecovery -> 0 + LongRest, _ -> definition.maximum + ShortRest, ShortRestAll -> definition.maximum + ShortRest, ShortRestAmount(amount) -> amount + _, _ -> 0 + } + change_resource_use(uses, definition.id, -recovered, definition.maximum) + }, + ) +} + +fn recover_custom_resources( + resources: List(CustomResource), + kind: RestKind, +) -> List(CustomResource) { + list.map(resources, fn(resource) { + let recovered = case kind, resource.recovery { + LongRest, ManualRecovery -> 0 + LongRest, _ -> resource.maximum + ShortRest, ShortRestAll -> resource.maximum + ShortRest, ShortRestAmount(amount) -> amount + _, _ -> 0 + } + CustomResource(..resource, spent: resource.spent - recovered |> int.max(0)) + }) +} + +fn change_custom_resource( + resources: List(CustomResource), + id: String, + delta: Int, +) -> List(CustomResource) { + list.map(resources, fn(resource) { + case resource.id == id { + False -> resource + True -> + CustomResource( + ..resource, + spent: resource.spent + delta + |> int.max(0) + |> int.min(resource.maximum), + ) + } + }) +} + +fn rest_undo(model: Model) -> RestUndo { + RestUndo( + current_hit_points: model.current_hit_points, + temporary_hit_points: model.temporary_hit_points, + hit_dice_spent: model.hit_dice_spent, + spell_slot_uses: model.spell_slot_uses, + resource_uses: model.resource_uses, + custom_resources: model.custom_resources, + conditions: model.conditions, + concentration: model.concentration, + death_save_successes: model.death_save_successes, + death_save_failures: model.death_save_failures, + ) +} + +fn complete_rest(model: Model) -> #(Model, Effect(Message)) { + case model.rest_kind { + NoRest -> #(model, effect.none()) + ShortRest -> { + let updated = + Model( + ..model, + current_hit_points: int.min( + character_hit_points(model), + effective_current_hit_points(model) + + model.rest_hit_points_recovered, + ), + hit_dice_spent: model.hit_dice_spent + model.rest_hit_dice_spent, + spell_slot_uses: case model.class_name { + "Warlock" -> [] + _ -> model.spell_slot_uses }, + resource_uses: recover_resource_uses(model, ShortRest), + custom_resources: recover_custom_resources( + model.custom_resources, + ShortRest, + ), + rest_kind: NoRest, + rest_hit_dice_spent: 0, + rest_hit_points_recovered: 0, + rest_undo: Ok(rest_undo(model)), + ) + update_and_sync(model, updated) + } + LongRest -> { + let updated = + Model( + ..model, + current_hit_points: character_hit_points(model), + temporary_hit_points: 0, + hit_dice_spent: 0, + spell_slot_uses: [], + resource_uses: recover_resource_uses(model, LongRest), + custom_resources: recover_custom_resources( + model.custom_resources, + LongRest, + ), + conditions: set_condition_value( + model.conditions, + "Exhaustion", + int.max(0, exhaustion_level(model) - 1), + ), + concentration: "", + death_save_successes: 0, + death_save_failures: 0, + rest_kind: NoRest, + rest_hit_dice_spent: 0, + rest_hit_points_recovered: 0, + rest_undo: Ok(rest_undo(model)), + ) + update_and_sync(model, updated) + } + } +} + +fn undo_rest(model: Model) -> #(Model, Effect(Message)) { + case model.rest_undo { + Error(Nil) -> #(model, effect.none()) + Ok(previous) -> + update_and_sync( + model, + Model( + ..model, + current_hit_points: previous.current_hit_points, + temporary_hit_points: previous.temporary_hit_points, + hit_dice_spent: previous.hit_dice_spent, + spell_slot_uses: previous.spell_slot_uses, + resource_uses: previous.resource_uses, + custom_resources: previous.custom_resources, + conditions: previous.conditions, + concentration: previous.concentration, + death_save_successes: previous.death_save_successes, + death_save_failures: previous.death_save_failures, + rest_undo: Error(Nil), ), - persist_saved_player_characters(saved_player_characters), ) + } +} + +fn exhaustion_level(model: Model) -> Int { + case + list.find(model.conditions, fn(condition) { condition.name == "Exhaustion" }) + { + Ok(condition) -> condition.value + Error(Nil) -> 0 + } +} + +fn d20_test_modifier(modifier: Int, exhaustion: Int) -> Int { + modifier - exhaustion * 2 +} + +fn condition_is_active(model: Model, name: String) -> Bool { + list.any(model.conditions, fn(condition) { condition.name == name }) +} + +fn default_d20_roll_mode(model: Model, label: String) -> RollMode { + case + condition_is_active(model, "Poisoned") + && label != "" + && !string.contains(label, "save") + { + True -> DisadvantageRoll + False -> NormalRoll + } +} + +fn snapshot_exhaustion_level(snapshot: Snapshot) -> Int { + list.fold(snapshot.conditions, 0, fn(level, condition) { + case string.split(condition, " ") { + ["Exhaustion", value] -> + case int.parse(value) { + Ok(value) -> value + Error(Nil) -> level + } + _ -> level } + }) +} + +fn set_condition_value( + conditions: List(CharacterCondition), + name: String, + value: Int, +) -> List(CharacterCondition) { + case value { + 0 -> list.filter(conditions, fn(condition) { condition.name != name }) + _ -> [ + CharacterCondition(name:, value:), + ..list.filter(conditions, fn(condition) { condition.name != name }) + ] } } +fn upsert_item_resource( + resources: List(ItemResource), + incoming: ItemResource, +) -> List(ItemResource) { + [ + incoming, + ..list.filter(resources, fn(resource) { resource.name != incoming.name }) + ] +} + +fn change_item_charge( + resources: List(ItemResource), + name: String, + delta: Int, +) -> List(ItemResource) { + list.map(resources, fn(resource) { + case resource.name == name { + False -> resource + True -> + ItemResource( + ..resource, + spent_charges: resource.spent_charges + delta + |> int.max(0) + |> int.min(resource.maximum_charges), + ) + } + }) +} + +fn attuned_item_count(resources: List(ItemResource)) -> Int { + resources |> list.filter(fn(resource) { resource.attuned }) |> list.length +} + +fn toggle_item_attunement( + resources: List(ItemResource), + name: String, +) -> List(ItemResource) { + let attuned_count = attuned_item_count(resources) + list.map(resources, fn(resource) { + case resource.name == name { + False -> resource + True -> + case resource.attuned || attuned_count < 3 { + True -> ItemResource(..resource, attuned: !resource.attuned) + False -> resource + } + } + }) +} + fn ensure_character_identity(character: StoredCharacter) -> StoredCharacter { let id = case character.id { "" -> browser.new_id() @@ -2697,6 +3903,21 @@ fn load_stored_character( notes: character.notes, other_proficiencies: character.other_proficiencies, health_increases: character.health_increases, + spell_slot_uses: character.spell_slot_uses, + resource_uses: character.resource_uses, + custom_resources: character.custom_resources, + death_save_successes: character.death_save_successes, + death_save_failures: character.death_save_failures, + inspiration: character.inspiration, + concentration: character.concentration, + conditions: character.conditions, + item_resources: character.item_resources, + pending_spell_name: "", + pending_spell_level: 0, + rest_kind: NoRest, + rest_hit_dice_spent: 0, + rest_hit_points_recovered: 0, + rest_undo: Error(Nil), class_progression: Error("Choose a class"), origin_details: Error("Choose a species and background"), spell_catalog: [], @@ -3044,6 +4265,21 @@ fn stored_character_json(character: StoredCharacter) -> json.Json { "healthIncreases", json.array(character.health_increases, health_increase_json), ), + #( + "spellSlotUses", + json.array(character.spell_slot_uses, spell_slot_use_json), + ), + #("resourceUses", json.array(character.resource_uses, resource_use_json)), + #( + "customResources", + json.array(character.custom_resources, custom_resource_json), + ), + #("deathSaveSuccesses", json.int(character.death_save_successes)), + #("deathSaveFailures", json.int(character.death_save_failures)), + #("inspiration", json.bool(character.inspiration)), + #("concentration", json.string(character.concentration)), + #("conditions", json.array(character.conditions, condition_json)), + #("itemResources", json.array(character.item_resources, item_resource_json)), ]) } @@ -3127,6 +4363,43 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { [], decode.list(health_increase_decoder()), ) + use spell_slot_uses <- decode.optional_field( + "spellSlotUses", + [], + decode.list(spell_slot_use_decoder()), + ) + use resource_uses <- decode.optional_field( + "resourceUses", + [], + decode.list(resource_use_decoder()), + ) + use custom_resources <- decode.optional_field( + "customResources", + [], + decode.list(custom_resource_decoder()), + ) + use death_save_successes <- decode.optional_field( + "deathSaveSuccesses", + 0, + decode.int, + ) + use death_save_failures <- decode.optional_field( + "deathSaveFailures", + 0, + decode.int, + ) + use inspiration <- decode.optional_field("inspiration", False, decode.bool) + use concentration <- decode.optional_field("concentration", "", decode.string) + use conditions <- decode.optional_field( + "conditions", + [], + decode.list(condition_decoder()), + ) + use item_resources <- decode.optional_field( + "itemResources", + [], + decode.list(item_resource_decoder()), + ) decode.success(StoredCharacter( id:, created_at:, @@ -3165,9 +4438,117 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { notes:, other_proficiencies:, health_increases:, + spell_slot_uses:, + resource_uses:, + custom_resources:, + death_save_successes:, + death_save_failures:, + inspiration:, + concentration:, + conditions:, + item_resources:, + )) +} + +fn spell_slot_use_json(slot: SpellSlotUse) -> json.Json { + json.object([ + #("level", json.int(slot.level)), + #("spent", json.int(slot.spent)), + ]) +} + +fn spell_slot_use_decoder() -> decode.Decoder(SpellSlotUse) { + use level <- decode.field("level", decode.int) + use spent <- decode.field("spent", decode.int) + decode.success(SpellSlotUse(level:, spent:)) +} + +fn resource_use_json(resource: ResourceUse) -> json.Json { + json.object([ + #("id", json.string(resource.id)), + #("spent", json.int(resource.spent)), + ]) +} + +fn resource_use_decoder() -> decode.Decoder(ResourceUse) { + use id <- decode.field("id", decode.string) + use spent <- decode.field("spent", decode.int) + decode.success(ResourceUse(id:, spent:)) +} + +fn recovery_rule_name(recovery: RecoveryRule) -> String { + case recovery { + ShortRestAll -> "short-all" + ShortRestAmount(amount) -> "short-" <> int.to_string(amount) + LongRestAll -> "long" + ManualRecovery -> "manual" + } +} + +fn recovery_rule_from_name(name: String) -> RecoveryRule { + case name { + "short-all" -> ShortRestAll + "short-1" -> ShortRestAmount(1) + "long" -> LongRestAll + _ -> ManualRecovery + } +} + +fn custom_resource_json(resource: CustomResource) -> json.Json { + json.object([ + #("id", json.string(resource.id)), + #("name", json.string(resource.name)), + #("maximum", json.int(resource.maximum)), + #("spent", json.int(resource.spent)), + #("recovery", json.string(recovery_rule_name(resource.recovery))), + ]) +} + +fn custom_resource_decoder() -> decode.Decoder(CustomResource) { + use id <- decode.field("id", decode.string) + use name <- decode.field("name", decode.string) + use maximum <- decode.field("maximum", decode.int) + use spent <- decode.field("spent", decode.int) + use recovery <- decode.field("recovery", decode.string) + decode.success(CustomResource( + id:, + name:, + maximum:, + spent:, + recovery: recovery_rule_from_name(recovery), )) } +fn condition_json(condition: CharacterCondition) -> json.Json { + json.object([ + #("name", json.string(condition.name)), + #("value", json.int(condition.value)), + ]) +} + +fn condition_decoder() -> decode.Decoder(CharacterCondition) { + use name <- decode.field("name", decode.string) + use value <- decode.optional_field("value", 0, decode.int) + decode.success(CharacterCondition(name:, value:)) +} + +fn item_resource_json(resource: ItemResource) -> json.Json { + json.object([ + #("name", json.string(resource.name)), + #("maximumCharges", json.int(resource.maximum_charges)), + #("spentCharges", json.int(resource.spent_charges)), + #("attuned", json.bool(resource.attuned)), + ]) +} + +fn item_resource_decoder() -> decode.Decoder(ItemResource) { + use name <- decode.field("name", decode.string) + use maximum_charges <- decode.optional_field("maximumCharges", 0, decode.int) + use spent_charges <- decode.optional_field("spentCharges", 0, decode.int) + use attuned <- decode.optional_field("attuned", False, decode.bool) + decode.success(ItemResource(name:, maximum_charges:, spent_charges:, attuned:)) +} + fn feature_selection_json(selection: FeatureSelection) -> json.Json { json.object([ #("id", json.string(selection.id)), @@ -3329,6 +4710,15 @@ fn model_to_stored_character(model: Model) -> StoredCharacter { notes: model.notes, other_proficiencies: model.other_proficiencies, health_increases: model.health_increases, + spell_slot_uses: model.spell_slot_uses, + resource_uses: model.resource_uses, + custom_resources: model.custom_resources, + death_save_successes: model.death_save_successes, + death_save_failures: model.death_save_failures, + inspiration: model.inspiration, + concentration: model.concentration, + conditions: model.conditions, + item_resources: model.item_resources, ) } @@ -3798,8 +5188,10 @@ fn roll_player_initiative(model: Model) -> #(Model, Effect(Message)) { ..model, dice_open: True, dice_target: InitiativeRoll(entry_id: entry.id), - dice_roll_mode: NormalRoll, - dice_modifier: int.to_string(entry.initiative_bonus), + dice_roll_mode: default_d20_roll_mode(model, "Initiative"), + dice_modifier: int.to_string( + entry.initiative_bonus - exhaustion_level(model) * 2, + ), dice_note: "Initiative", dice_count: "1", dice_sides: 20, @@ -4567,6 +5959,16 @@ fn view(model: Model) -> Element(Message) { True, True -> view_health_configuration(model) _, _ -> html.text("") }, + case character_ready, model.rest_kind { + True, NoRest -> html.text("") + True, _ -> view_rest_dialog(model) + _, _ -> html.text("") + }, + case character_ready, model.pending_spell_name { + True, "" -> html.text("") + True, _ -> view_spell_cast_dialog(model) + _, _ -> html.text("") + }, case model.dice_open { True -> view_dice_window(model) False -> html.text("") @@ -5609,23 +7011,218 @@ fn view_first_character(model: Model) -> Element(Message) { [html.text("Host as Game Master")], ), ]), - case model.connection { - ConnectionFailed(reason:) -> - html.p( - [attribute.class("connection-error"), attribute.role("alert")], - [html.text(reason)], - ) - _ -> html.text("") - }, - case model.import_status { - "" -> html.text("") - status -> - html.p([attribute.class("import-status"), attribute.role("status")], [ - html.text(status), - ]) - }, - ]), - ]) + case model.connection { + ConnectionFailed(reason:) -> + html.p( + [attribute.class("connection-error"), attribute.role("alert")], + [html.text(reason)], + ) + _ -> html.text("") + }, + case model.import_status { + "" -> html.text("") + status -> + html.p([attribute.class("import-status"), attribute.role("status")], [ + html.text(status), + ]) + }, + ]), + ]) +} + +fn view_spell_cast_dialog(model: Model) -> Element(Message) { + let slot_levels = case model.pending_spell_level { + 0 -> [0] + level -> available_spell_slot_levels(model, level) + } + html.aside( + [ + attribute.class("health-choice-window resource-dialog"), + attribute.role("dialog"), + attribute.aria_modal(True), + attribute.aria_label("Cast spell"), + ], + [ + html.div([attribute.class("health-choice-card")], [ + html.span([attribute.class("eyebrow")], [html.text("CAST SPELL")]), + html.h2([], [html.text(model.pending_spell_name)]), + case model.concentration { + "" -> html.text("") + current -> + html.p([attribute.class("effect-note")], [ + html.text( + "If this spell requires concentration, it replaces " + <> current + <> ".", + ), + ]) + }, + case slot_levels { + [] -> + html.p( + [attribute.class("connection-error"), attribute.role("alert")], + [ + html.text("No spell slot of this level or higher is available."), + ], + ) + _ -> + html.div( + [attribute.class("cast-slot-options")], + list.map(slot_levels, fn(level) { + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button"), + event.on_click(UserConfirmedSpellCast(level)), + ], + [ + html.text(case level { + 0 -> "Cast cantrip" + level -> "Use level " <> int.to_string(level) <> " slot" + }), + ], + ) + }), + ) + }, + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserCancelledSpellCast), + ], + [html.text("Cancel")], + ), + ]), + ], + ) +} + +fn view_rest_dialog(model: Model) -> Element(Message) { + let maximum_hit_points = character_hit_points(model) + let current_hit_points = effective_current_hit_points(model) + html.aside( + [ + attribute.class("health-choice-window resource-dialog"), + attribute.role("dialog"), + attribute.aria_modal(True), + attribute.aria_label(case model.rest_kind { + ShortRest -> "Short rest" + LongRest -> "Long rest" + NoRest -> "Rest" + }), + ], + [ + html.div([attribute.class("health-choice-card rest-card")], [ + html.span([attribute.class("eyebrow")], [ + html.text(case model.rest_kind { + ShortRest -> "SHORT REST" + LongRest -> "LONG REST" + NoRest -> "REST" + }), + ]), + html.h2([], [html.text("Recovery preview")]), + case model.rest_kind { + ShortRest -> + html.div([], [ + html.p([], [ + html.text( + "HP " + <> int.to_string(current_hit_points) + <> " → " + <> int.to_string(int.min( + maximum_hit_points, + current_hit_points + model.rest_hit_points_recovered, + )), + ), + ]), + html.p([], [ + html.text( + "Hit Dice " + <> int.to_string( + model.level + - model.hit_dice_spent + - model.rest_hit_dice_spent, + ) + <> " / " + <> int.to_string(model.level) + <> " d" + <> int.to_string(class_hit_die(model.class_name)), + ), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.disabled( + model.hit_dice_spent + model.rest_hit_dice_spent + >= model.level + || current_hit_points + model.rest_hit_points_recovered + >= maximum_hit_points, + ), + event.on_click(UserSpentRestHitDie), + ], + [html.text("Spend Hit Die")], + ), + html.p([attribute.class("effect-note")], [ + html.text("Short-rest resources recover when you finish."), + ]), + ]) + LongRest -> + html.div([attribute.class("rest-summary")], [ + html.p([], [ + html.text( + "HP " + <> int.to_string(current_hit_points) + <> " → " + <> int.to_string(maximum_hit_points), + ), + ]), + html.p([], [html.text("Hit Dice → All available")]), + html.p([], [html.text("Spell slots → All restored")]), + html.p([], [html.text("Long-rest resources → Restored")]), + case exhaustion_level(model) { + 0 -> html.text("") + level -> + html.p([], [ + html.text( + "Exhaustion " + <> int.to_string(level) + <> " → " + <> int.to_string(level - 1), + ), + ]) + }, + ]) + NoRest -> html.text("") + }, + html.div([attribute.class("rest-dialog-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserCancelledRest), + ], + [html.text("Cancel")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button"), + event.on_click(UserCompletedRest), + ], + [ + html.text(case model.rest_kind { + ShortRest -> "Finish Short Rest" + LongRest -> "Complete Long Rest" + NoRest -> "Finish" + }), + ], + ), + ]), + ]), + ], + ) } fn view_global_dice_button() -> Element(Message) { @@ -6729,6 +8326,18 @@ fn view_dice_window(model: Model) -> Element(Message) { ), ]), view_secret_roll_controls(model), + case supports_roll_mode, model.inspiration { + True, True -> + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button inspiration-roll-button"), + event.on_click(UserSpentInspirationOnRoll), + ], + [html.text("Spend Inspiration for Advantage")], + ) + _, _ -> html.text("") + }, html.button( [ attribute.type_("button"), @@ -7021,9 +8630,34 @@ fn view_remote_sheets(model: Model) -> Element(Message) { html.text("AC " <> int.to_string(snapshot.armor_class)), ]), html.span([], [ - html.text("HP " <> int.to_string(snapshot.hit_points)), + html.text( + "HP " + <> int.to_string(snapshot.current_hit_points) + <> "/" + <> int.to_string(snapshot.hit_points), + ), ]), ]), + html.div( + [attribute.class("remote-resource-summary")], + list.append( + list.map(list.take(snapshot.resources, 5), fn(resource) { + html.span([], [ + html.text( + resource.name + <> " " + <> int.to_string(resource.current) + <> "/" + <> int.to_string(resource.maximum), + ), + ]) + }), + case snapshot.concentration { + "" -> [] + _ -> [html.span([], [html.text("Concentrating")])] + }, + ), + ), ], ) }), @@ -7223,12 +8857,17 @@ fn view_snapshot(snapshot: Snapshot) -> Element(Message) { ), stat_block( "HIT POINTS", - int.to_string(snapshot.hit_points), - "Maximum health", + int.to_string(snapshot.current_hit_points) + <> " / " + <> int.to_string(snapshot.hit_points), + "Current / maximum", ), rollable_stat_block( "INITIATIVE", - snapshot.initiative, + d20_test_modifier( + snapshot.initiative, + snapshot_exhaustion_level(snapshot), + ), "Dexterity", display_name <> " initiative", ), @@ -7247,6 +8886,7 @@ fn view_snapshot(snapshot: Snapshot) -> Element(Message) { }), snapshot_quick_stat("GOLD", int.to_string(snapshot.gold) <> " gp"), ]), + view_snapshot_resources(snapshot), view_snapshot_abilities(snapshot), view_snapshot_skills(snapshot), snapshot_section( @@ -7267,6 +8907,49 @@ fn view_snapshot(snapshot: Snapshot) -> Element(Message) { ]) } +fn view_snapshot_resources(snapshot: Snapshot) -> Element(Message) { + let resources = + list.map(snapshot.resources, fn(resource) { + html.span([attribute.class("inspector-proficiency")], [ + html.text( + resource.name + <> " " + <> int.to_string(resource.current) + <> "/" + <> int.to_string(resource.maximum), + ), + ]) + }) + let inspiration = case snapshot.inspiration { + True -> [ + html.span([attribute.class("inspector-proficiency")], [ + html.text("Inspiration"), + ]), + ] + False -> [] + } + let concentration = case snapshot.concentration { + "" -> [] + spell -> [ + html.span([attribute.class("inspector-proficiency")], [ + html.text("Concentrating: " <> spell), + ]), + ] + } + let conditions = + list.map(snapshot.conditions, fn(condition) { + html.span([attribute.class("inspector-proficiency")], [ + html.text(condition), + ]) + }) + let status = + list.append( + resources, + list.append(inspiration, list.append(concentration, conditions)), + ) + snapshot_section("PLAY RESOURCES", list.length(status), status) +} + fn snapshot_quick_stat(label: String, value: String) -> Element(message) { html.div([], [ html.span([], [html.text(label)]), @@ -7326,7 +9009,10 @@ fn view_snapshot_abilities(snapshot: Snapshot) -> Element(Message) { attribute.class("roll-modifier"), event.on_click(UserOpenedDiceRoll( display_name <> " " <> name <> " check", - modifier(score), + d20_test_modifier( + modifier(score), + snapshot_exhaustion_level(snapshot), + ), )), ], [html.text(signed(modifier(score)) <> " check")], @@ -7337,7 +9023,10 @@ fn view_snapshot_abilities(snapshot: Snapshot) -> Element(Message) { attribute.class("roll-modifier"), event.on_click(UserOpenedDiceRoll( display_name <> " " <> name <> " save", - saving_throw, + d20_test_modifier( + saving_throw, + snapshot_exhaustion_level(snapshot), + ), )), ], [html.text(signed(saving_throw) <> " save")], @@ -7376,7 +9065,10 @@ fn view_snapshot_skills(snapshot: Snapshot) -> Element(Message) { ), event.on_click(UserOpenedDiceRoll( display_name <> " " <> skill.name, - skill.score, + d20_test_modifier( + skill.score, + snapshot_exhaustion_level(snapshot), + ), )), ], [html.text(signed(skill.score))], @@ -7605,94 +9297,505 @@ fn view_identity(model: Model) -> Element(Message) { list.map(model.species, fn(item) { #(item.name, item.source) }), UserChangedSpecies, ), - choice_select( - model, - "Background", - background_description(model), - model.background_name, - list.map(model.backgrounds, fn(item) { #(item.name, item.source) }), - UserChangedBackground, + choice_select( + model, + "Background", + background_description(model), + model.background_name, + list.map(model.backgrounds, fn(item) { #(item.name, item.source) }), + UserChangedBackground, + ), + ]), + ]) +} + +fn view_core_abilities(model: Model) -> Element(Message) { + html.section([attribute.class("panel progression-panel")], [ + section_heading("02", "Abilities", "Ability scores and skill proficiencies"), + html.div( + [ + attribute.class("progression-tabs"), + attribute.role("tablist"), + attribute.aria_label("Abilities"), + ], + [ + progression_tab( + "Ability scores", + model.active_core_tab == CoreAbilityScores, + AbilityScoresTab, + tab_incomplete_count(model, AbilityScoresTab), + ), + progression_tab( + "Skills", + model.active_core_tab == CoreSkillProficiencies, + SkillProficienciesTab, + tab_incomplete_count(model, SkillProficienciesTab), + ), + ], + ), + case model.active_core_tab { + CoreAbilityScores -> view_ability_scores(model) + CoreSkillProficiencies -> view_skill_proficiencies(model) + }, + ]) +} + +fn view_character_progression(model: Model) -> Element(Message) { + html.section([attribute.class("panel progression-panel")], [ + section_heading( + "03", + "Class progression", + "Class features, origins, feats, and level advancement", + ), + html.div( + [ + attribute.class("progression-tabs"), + attribute.role("tablist"), + attribute.aria_label("Class progression"), + ], + [ + progression_tab( + "Class features", + model.active_progression_tab == ProgressionClassFeatures, + ClassFeaturesTab, + tab_incomplete_count(model, ClassFeaturesTab), + ), + progression_tab( + "Origins", + model.active_progression_tab == ProgressionOrigins, + OriginsTab, + tab_incomplete_count(model, OriginsTab), + ), + progression_tab( + "Feats", + model.active_progression_tab == ProgressionFeats, + FeatsTab, + tab_incomplete_count(model, FeatsTab), + ), + progression_tab( + "Level up", + model.active_progression_tab == ProgressionLevelUp, + LevelUpTab, + tab_incomplete_count(model, LevelUpTab), + ), + ], + ), + case model.active_progression_tab { + ProgressionClassFeatures -> view_class_features(model) + ProgressionOrigins -> view_origins(model) + ProgressionFeats -> view_feats(model) + ProgressionLevelUp -> view_level_up(model) + }, + ]) +} + +fn view_play_resources(model: Model) -> Element(Message) { + let spell_slots = + [1, 2, 3, 4, 5, 6, 7, 8, 9] + |> list.filter(fn(level) { spell_slot_maximum(model, level) > 0 }) + let resources = class_resource_definitions(model) + html.div([attribute.class("play-resources")], [ + html.div([attribute.class("play-resources-heading")], [ + html.div([], [ + html.span([attribute.class("eyebrow")], [html.text("PLAY RESOURCES")]), + html.h3([], [html.text("Session dashboard")]), + ]), + html.div([attribute.class("rest-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserOpenedRest(ShortRest)), + ], + [html.text("Short rest")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserOpenedRest(LongRest)), + ], + [html.text("Long rest")], + ), + case model.rest_undo { + Ok(_) -> + html.button( + [ + attribute.type_("button"), + attribute.class("text-button"), + event.on_click(UserUndidRest), + ], + [html.text("Undo last rest")], + ) + Error(Nil) -> html.text("") + }, + ]), + ]), + html.div([attribute.class("play-resource-grid")], [ + html.article([attribute.class("play-resource-group")], [ + html.h4([], [ + html.text(case model.class_name { + "Warlock" -> "Pact Magic" + _ -> "Spell slots" + }), + ]), + case spell_slots { + [] -> + html.p([attribute.class("muted-copy")], [ + html.text("No spell slots at this level."), + ]) + _ -> + html.div( + [attribute.class("counter-grid")], + list.map(spell_slots, fn(level) { + view_counter( + "slot:" <> int.to_string(level), + "Level " <> int.to_string(level), + spell_slot_maximum(model, level), + spell_slot_spent(model, level), + UserSpentSpellSlot(level), + UserRestoredSpellSlot(level), + "", + False, + ) + }), + ) + }, + ]), + html.article([attribute.class("play-resource-group")], [ + html.h4([], [html.text("Class resources")]), + case resources, model.custom_resources { + [], [] -> + html.p([attribute.class("muted-copy")], [ + html.text("Add a custom counter for a limited-use feature."), + ]) + _, _ -> + html.div( + [attribute.class("counter-grid")], + list.append( + list.map(resources, fn(resource) { + view_counter( + resource.id, + resource.name, + resource.maximum, + resource_spent(model, resource.id), + UserSpentResource(resource.id), + UserRestoredResource(resource.id), + resource.source + <> case resource.die_sides { + 0 -> "" + sides -> " · d" <> int.to_string(sides) + }, + False, + ) + }), + list.map(model.custom_resources, fn(resource) { + view_counter( + resource.id, + resource.name, + resource.maximum, + resource.spent, + UserSpentResource(resource.id), + UserRestoredResource(resource.id), + "Custom · " <> recovery_label(resource.recovery), + True, + ) + }), + ), + ) + }, + html.div([attribute.class("custom-resource-form")], [ + html.input([ + attribute.type_("text"), + attribute.placeholder("Custom resource"), + attribute.value(model.custom_resource_name), + event.on_input(UserChangedCustomResourceName), + ]), + html.input([ + attribute.type_("number"), + attribute.min("1"), + attribute.max("999"), + attribute.value(model.custom_resource_maximum), + attribute.aria_label("Custom resource maximum"), + event.on_input(UserChangedCustomResourceMaximum), + ]), + html.select([event.on_change(UserChangedCustomResourceRecovery)], [ + html.option([attribute.value("long")], "Long rest"), + html.option([attribute.value("short-all")], "Short rest"), + html.option([attribute.value("short-1")], "+1 on short rest"), + html.option([attribute.value("manual")], "Manual"), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserAddedCustomResource), + ], + [html.text("Add counter")], + ), + ]), + ]), + html.article([attribute.class("play-resource-group play-state-group")], [ + html.h4([], [html.text("Status")]), + html.div([attribute.class("status-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.classes([ + #("status-toggle", True), + #("active", model.inspiration), + ]), + attribute.aria_pressed(case model.inspiration { + True -> "true" + False -> "false" + }), + event.on_click(UserToggledInspiration), + ], + [ + html.text(case model.inspiration { + True -> "Inspiration available" + False -> "Inspiration spent" + }), + ], + ), + case model.concentration { + "" -> + html.span([attribute.class("status-chip")], [ + html.text("No concentration"), + ]) + spell -> + html.span([attribute.class("status-chip active")], [ + html.text("Concentrating: " <> spell), + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Clear concentration"), + event.on_click(UserClearedConcentration), + ], + [html.text("×")], + ), + ]) + }, + ]), + view_death_saves(model), + view_character_conditions(model), + ]), + ]), + ]) +} + +fn view_counter( + id: String, + name: String, + maximum: Int, + spent: Int, + spend: Message, + restore: Message, + source: String, + removable: Bool, +) -> Element(Message) { + html.div([attribute.class("resource-counter")], [ + html.div([attribute.class("resource-counter-label")], [ + html.strong([], [html.text(name)]), + html.small([], [html.text(source)]), + ]), + html.div([attribute.class("resource-counter-controls")], [ + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Spend " <> name), + attribute.disabled(spent >= maximum), + event.on_click(spend), + ], + [html.text("−")], + ), + html.span([], [ + html.strong([], [html.text(int.to_string(maximum - spent))]), + html.text(" / " <> int.to_string(maximum)), + ]), + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Restore " <> name), + attribute.disabled(spent == 0), + event.on_click(restore), + ], + [html.text("+")], ), + case removable { + True -> + html.button( + [ + attribute.type_("button"), + attribute.class("counter-delete"), + attribute.aria_label("Delete " <> name), + event.on_click(UserRemovedCustomResource(id)), + ], + [html.text("×")], + ) + False -> html.text("") + }, ]), ]) } -fn view_core_abilities(model: Model) -> Element(Message) { - html.section([attribute.class("panel progression-panel")], [ - section_heading("02", "Abilities", "Ability scores and skill proficiencies"), - html.div( - [ - attribute.class("progression-tabs"), - attribute.role("tablist"), - attribute.aria_label("Abilities"), - ], - [ - progression_tab( - "Ability scores", - model.active_core_tab == CoreAbilityScores, - AbilityScoresTab, - tab_incomplete_count(model, AbilityScoresTab), - ), - progression_tab( - "Skills", - model.active_core_tab == CoreSkillProficiencies, - SkillProficienciesTab, - tab_incomplete_count(model, SkillProficienciesTab), - ), - ], - ), - case model.active_core_tab { - CoreAbilityScores -> view_ability_scores(model) - CoreSkillProficiencies -> view_skill_proficiencies(model) - }, - ]) +fn recovery_label(recovery: RecoveryRule) -> String { + case recovery { + ShortRestAll -> "Short rest" + ShortRestAmount(amount) -> "+" <> int.to_string(amount) <> " on short rest" + LongRestAll -> "Long rest" + ManualRecovery -> "Manual" + } } -fn view_character_progression(model: Model) -> Element(Message) { - html.section([attribute.class("panel progression-panel")], [ - section_heading( - "03", - "Class progression", - "Class features, origins, feats, and level advancement", - ), +fn view_death_saves(model: Model) -> Element(Message) { + case effective_current_hit_points(model) { + 0 -> + html.div([attribute.class("death-saves")], [ + html.div([], [ + html.strong([], [html.text("Death saves")]), + html.span([], [ + html.text( + "Successes " + <> int.to_string(model.death_save_successes) + <> "/3 · Failures " + <> int.to_string(model.death_save_failures) + <> "/3", + ), + ]), + ]), + html.div([attribute.class("death-save-actions")], [ + html.button( + [ + attribute.type_("button"), + event.on_click(UserRecordedDeathSaveSuccess), + ], + [html.text("Success")], + ), + html.button( + [ + attribute.type_("button"), + event.on_click(UserRecordedDeathSaveFailure(1)), + ], + [html.text("Failure")], + ), + html.button( + [ + attribute.type_("button"), + event.on_click(UserRecordedDeathSaveFailure(2)), + ], + [html.text("Natural 1")], + ), + html.button( + [ + attribute.type_("button"), + event.on_click(UserRecordedDeathSaveNaturalTwenty), + ], + [html.text("Natural 20")], + ), + html.button( + [attribute.type_("button"), event.on_click(UserResetDeathSaves)], + [html.text("Reset")], + ), + ]), + ]) + _ -> html.text("") + } +} + +fn view_character_conditions(model: Model) -> Element(Message) { + let conditions = [ + "Blinded", + "Charmed", + "Frightened", + "Grappled", + "Incapacitated", + "Invisible", + "Paralyzed", + "Poisoned", + "Prone", + "Restrained", + "Stunned", + "Unconscious", + ] + html.div([attribute.class("character-conditions")], [ html.div( - [ - attribute.class("progression-tabs"), - attribute.role("tablist"), - attribute.aria_label("Class progression"), - ], - [ - progression_tab( - "Class features", - model.active_progression_tab == ProgressionClassFeatures, - ClassFeaturesTab, - tab_incomplete_count(model, ClassFeaturesTab), - ), - progression_tab( - "Origins", - model.active_progression_tab == ProgressionOrigins, - OriginsTab, - tab_incomplete_count(model, OriginsTab), - ), - progression_tab( - "Feats", - model.active_progression_tab == ProgressionFeats, - FeatsTab, - tab_incomplete_count(model, FeatsTab), - ), - progression_tab( - "Level up", - model.active_progression_tab == ProgressionLevelUp, - LevelUpTab, - tab_incomplete_count(model, LevelUpTab), - ), - ], + [attribute.class("condition-list")], + list.map(model.conditions, fn(condition) { + case condition.name { + "Exhaustion" -> html.text("") + name -> + html.span([attribute.class("condition-chip")], [ + html.text(name), + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Remove " <> name), + event.on_click(UserRemovedCondition(name)), + ], + [html.text("×")], + ), + ]) + } + }), ), - case model.active_progression_tab { - ProgressionClassFeatures -> view_class_features(model) - ProgressionOrigins -> view_origins(model) - ProgressionFeats -> view_feats(model) - ProgressionLevelUp -> view_level_up(model) + html.div([attribute.class("condition-controls")], [ + html.select( + [ + attribute.value(model.condition_choice), + event.on_change(UserChangedConditionChoice), + ], + [ + html.option([attribute.value("")], "Add condition…"), + ..list.map(conditions, fn(name) { + html.option( + [ + attribute.value(name), + attribute.disabled( + list.any(model.conditions, fn(condition) { + condition.name == name + }), + ), + ], + name, + ) + }) + ], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + attribute.disabled(model.condition_choice == ""), + event.on_click(UserAddedCondition), + ], + [html.text("Apply")], + ), + html.label([attribute.class("exhaustion-control")], [ + html.span([], [html.text("Exhaustion")]), + html.input([ + attribute.type_("number"), + attribute.min("0"), + attribute.max("6"), + attribute.value(int.to_string(exhaustion_level(model))), + event.on_input(UserChangedExhaustion), + ]), + ]), + ]), + case exhaustion_level(model) { + 0 -> html.text("") + level -> + html.small([attribute.class("effect-note")], [ + html.text( + "Exhaustion " + <> int.to_string(level) + <> ": −" + <> int.to_string(level * 2) + <> " to D20 Tests; speed reduced by " + <> int.to_string(level * 5) + <> " ft.", + ), + ]) }, ]) } @@ -7704,6 +9807,7 @@ fn view_character_resources(model: Model) -> Element(Message) { "Equipment & companions", "Inventory, summons, spell preparation, and campaign notes", ), + view_play_resources(model), html.div( [ attribute.class("progression-tabs"), @@ -7843,7 +9947,7 @@ fn view_ability_scores(model: Model) -> Element(Message) { attribute.aria_label("Roll " <> ability_name(ability) <> " check"), event.on_click(UserOpenedDiceRoll( ability_name(ability) <> " check", - modifier, + d20_test_modifier(modifier, exhaustion_level(model)), )), ], [html.text(signed(modifier))], @@ -8339,7 +10443,10 @@ fn view_skill_proficiencies(model: Model) -> Element(Message) { attribute.type_("button"), attribute.class("roll-modifier"), attribute.aria_label("Roll " <> skill.name), - event.on_click(UserOpenedDiceRoll(skill.name, total)), + event.on_click(UserOpenedDiceRoll( + skill.name, + d20_test_modifier(total, exhaustion_level(model)), + )), ], [html.text(signed(total))], ), @@ -8761,7 +10868,7 @@ fn view_equipment(model: Model) -> Element(Message) { ), rollable_stat_block( "ATTACK", - primary_attack_bonus(model), + d20_test_modifier(primary_attack_bonus(model), exhaustion_level(model)), equipped_weapon_name(model.equipment), "Attack", ), @@ -8770,6 +10877,11 @@ fn view_equipment(model: Model) -> Element(Message) { int.to_string(inventory_quantity(model.equipment)), "Carried quantity", ), + stat_block( + "ATTUNED", + int.to_string(attuned_item_count(model.item_resources)) <> " / 3", + "Magic items", + ), ]), html.div([attribute.class("inventory-toolbar")], [ html.div([attribute.class("currency-grid")], [ @@ -8870,7 +10982,10 @@ fn view_equipment(model: Model) -> Element(Message) { attribute.aria_label("Roll " <> proficiency <> " check"), event.on_click(UserOpenedDiceRoll( proficiency <> " check", - proficiency_bonus(model.level), + d20_test_modifier( + proficiency_bonus(model.level), + exhaustion_level(model), + ), )), ], [html.text("Roll")], @@ -8947,6 +11062,7 @@ fn view_equipment(model: Model) -> Element(Message) { }), ]), ]), + view_item_resource_controls(model, item.name), html.div([attribute.class("equipment-actions")], [ case item.category { "Tool" -> @@ -8955,22 +11071,32 @@ fn view_equipment(model: Model) -> Element(Message) { attribute.type_("button"), attribute.class("equipment-roll-button"), attribute.aria_label("Roll " <> item.name <> " check"), - event.on_click( - UserOpenedDiceRoll( - item.name <> " check", + event.on_click(UserOpenedDiceRoll( + item.name <> " check", + d20_test_modifier( case list.contains(model.other_proficiencies, item.name) { True -> proficiency_bonus(model.level) False -> 0 }, + exhaustion_level(model), ), - ), + )), ], [html.text("Roll")], ) _ -> html.text("") }, + html.button( + [ + attribute.type_("button"), + attribute.class("equipment-use-button"), + attribute.disabled(item.quantity == 0), + event.on_click(UserUsedEquipment(item.name)), + ], + [html.text("Use")], + ), html.button( [ attribute.type_("button"), @@ -8992,6 +11118,84 @@ fn view_equipment(model: Model) -> Element(Message) { ]) } +fn view_item_resource_controls(model: Model, name: String) -> Element(Message) { + case list.find(model.item_resources, fn(resource) { resource.name == name }) { + Error(Nil) -> + html.div([attribute.class("item-resource-setup")], [ + html.input([ + attribute.type_("number"), + attribute.min("1"), + attribute.max("999"), + attribute.value(model.item_charge_maximum), + attribute.aria_label("Maximum charges for " <> name), + event.on_input(UserChangedItemChargeMaximum), + ]), + html.button( + [ + attribute.type_("button"), + attribute.class("text-button"), + event.on_click(UserConfiguredItemCharges(name)), + ], + [html.text("Track charges")], + ), + ]) + Ok(resource) -> + html.div([attribute.class("item-resource-controls")], [ + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Spend charge for " <> name), + attribute.disabled( + resource.spent_charges >= resource.maximum_charges, + ), + event.on_click(UserSpentItemCharge(name)), + ], + [html.text("−")], + ), + html.span([], [ + html.text( + int.to_string(resource.maximum_charges - resource.spent_charges) + <> " / " + <> int.to_string(resource.maximum_charges) + <> " charges", + ), + ]), + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Restore charge for " <> name), + attribute.disabled(resource.spent_charges == 0), + event.on_click(UserRestoredItemCharge(name)), + ], + [html.text("+")], + ), + html.button( + [ + attribute.type_("button"), + attribute.classes([ + #("attunement-toggle", True), + #("active", resource.attuned), + ]), + attribute.disabled( + !resource.attuned && attuned_item_count(model.item_resources) >= 3, + ), + attribute.aria_pressed(case resource.attuned { + True -> "true" + False -> "false" + }), + event.on_click(UserToggledItemAttunement(name)), + ], + [ + html.text(case resource.attuned { + True -> "Attuned" + False -> "Attune" + }), + ], + ), + ]) + } +} + fn view_equipment_description(model: Model, name: String) -> Element(Message) { case list.find(model.equipment_catalog, fn(item) { item.name == name }) { Ok(item) -> @@ -9780,7 +11984,7 @@ fn view_spells(model: Model) -> Element(Message) { 0 -> cantrips >= cantrip_capacity _ -> prepared >= capacity } - html.label( + html.article( [ attribute.classes([ #("spell-row", True), @@ -9791,6 +11995,10 @@ fn view_spells(model: Model) -> Element(Message) { html.input([ attribute.type_("checkbox"), attribute.checked(selected), + attribute.aria_label(case selected { + True -> "Remove " <> spell.name + False -> "Prepare " <> spell.name + }), attribute.disabled( !selected && { @@ -9829,6 +12037,21 @@ fn view_spells(model: Model) -> Element(Message) { html.p([attribute.class("spell-description")], [ html.text(spell.description), ]), + case selected { + True -> + html.button( + [ + attribute.type_("button"), + attribute.class("spell-cast-button"), + event.on_click(UserOpenedSpellCast( + spell.name, + spell.level, + )), + ], + [html.text("Cast")], + ) + False -> html.text("") + }, ], ) }), @@ -10114,7 +12337,12 @@ fn view_sheet(model: Model) -> Element(Message) { }, ), health_stat_block(model, hit_points), - rollable_stat_block("INITIATIVE", initiative, "Dexterity", "Initiative"), + rollable_stat_block( + "INITIATIVE", + d20_test_modifier(initiative, exhaustion_level(model)), + "Dexterity", + "Initiative", + ), stat_block( "PROF. BONUS", signed(proficiency_bonus(model.level)), @@ -10152,7 +12380,7 @@ fn view_sheet(model: Model) -> Element(Message) { ), event.on_click(UserOpenedDiceRoll( ability_name(ability) <> " saving throw", - value, + d20_test_modifier(value, exhaustion_level(model)), )), ], [html.text(signed(value))], @@ -10736,6 +12964,68 @@ fn to_snapshot(model: Model) -> Snapshot { True -> spellcasting_ability_name(model.class_name) False -> "" }, + resources: snapshot_resources(model), + concentration: model.concentration, + inspiration: model.inspiration, + conditions: list.map(model.conditions, fn(condition) { + case condition.name, condition.value { + "Exhaustion", value -> "Exhaustion " <> int.to_string(value) + name, _ -> name + } + }), + ) +} + +fn snapshot_resources(model: Model) -> List(SnapshotResource) { + let spell_slots = + [1, 2, 3, 4, 5, 6, 7, 8, 9] + |> list.filter(fn(level) { spell_slot_maximum(model, level) > 0 }) + |> list.map(fn(level) { + let maximum = spell_slot_maximum(model, level) + SnapshotResource( + name: "Level " <> int.to_string(level) <> " slots", + current: maximum - spell_slot_spent(model, level), + maximum:, + ) + }) + let class_resources = + class_resource_definitions(model) + |> list.map(fn(resource) { + SnapshotResource( + name: resource.name, + current: resource.maximum - resource_spent(model, resource.id), + maximum: resource.maximum, + ) + }) + let custom_resources = + list.map(model.custom_resources, fn(resource) { + SnapshotResource( + name: resource.name, + current: resource.maximum - resource.spent, + maximum: resource.maximum, + ) + }) + let hit_dice = [ + SnapshotResource( + name: "Hit Dice", + current: model.level - model.hit_dice_spent, + maximum: model.level, + ), + ] + let item_resources = + list.map(model.item_resources, fn(resource) { + SnapshotResource( + name: resource.name <> " charges", + current: resource.maximum_charges - resource.spent_charges, + maximum: resource.maximum_charges, + ) + }) + list.append( + hit_dice, + list.append( + class_resources, + list.append(spell_slots, list.append(custom_resources, item_resources)), + ), ) } @@ -10899,10 +13189,11 @@ fn snapshot_spells(model: Model) -> List(SnapshotSpell) { } fn character_speed(model: Model) -> Int { - case model.origin_details { + let base = case model.origin_details { Ok(details) -> details.speed Error(_) -> 30 } + int.max(0, base - exhaustion_level(model) * 5) } fn character_armor_class(model: Model) -> Int { diff --git a/src/sheets/peer.gleam b/src/sheets/peer.gleam index 354ecec..6be9391 100644 --- a/src/sheets/peer.gleam +++ b/src/sheets/peer.gleam @@ -55,6 +55,10 @@ pub type SnapshotSpell { ) } +pub type SnapshotResource { + SnapshotResource(name: String, current: Int, maximum: Int) +} + pub type Snapshot { Snapshot( owner: String, @@ -86,6 +90,10 @@ pub type Snapshot { hit_die: Int, spell_save_dc: Int, spellcasting_ability: String, + resources: List(SnapshotResource), + concentration: String, + inspiration: Bool, + conditions: List(String), ) } @@ -535,6 +543,10 @@ pub fn snapshot_json(snapshot: Snapshot) -> json.Json { #("hitDie", json.int(snapshot.hit_die)), #("spellSaveDc", json.int(snapshot.spell_save_dc)), #("spellcastingAbility", json.string(snapshot.spellcasting_ability)), + #("resources", json.array(snapshot.resources, snapshot_resource_json)), + #("concentration", json.string(snapshot.concentration)), + #("inspiration", json.bool(snapshot.inspiration)), + #("conditions", json.array(snapshot.conditions, json.string)), ]) } @@ -627,6 +639,18 @@ pub fn snapshot_decoder() -> decode.Decoder(Snapshot) { "", decode.string, ) + use resources <- decode.optional_field( + "resources", + [], + decode.list(snapshot_resource_decoder()), + ) + use concentration <- decode.optional_field("concentration", "", decode.string) + use inspiration <- decode.optional_field("inspiration", False, decode.bool) + use conditions <- decode.optional_field( + "conditions", + [], + decode.list(decode.string), + ) decode.success(Snapshot( owner:, character_id:, @@ -657,9 +681,28 @@ pub fn snapshot_decoder() -> decode.Decoder(Snapshot) { hit_die:, spell_save_dc:, spellcasting_ability:, + resources:, + concentration:, + inspiration:, + conditions:, )) } +fn snapshot_resource_json(resource: SnapshotResource) -> json.Json { + json.object([ + #("name", json.string(resource.name)), + #("current", json.int(resource.current)), + #("maximum", json.int(resource.maximum)), + ]) +} + +fn snapshot_resource_decoder() -> decode.Decoder(SnapshotResource) { + use name <- decode.field("name", decode.string) + use current <- decode.field("current", decode.int) + use maximum <- decode.field("maximum", decode.int) + decode.success(SnapshotResource(name:, current:, maximum:)) +} + fn snapshot_abilities_json(abilities: SnapshotAbilities) -> json.Json { json.object([ #("strength", json.int(abilities.strength)), diff --git a/test/sheets_test.gleam b/test/sheets_test.gleam index f940aa4..5a916c9 100644 --- a/test/sheets_test.gleam +++ b/test/sheets_test.gleam @@ -4,7 +4,8 @@ import sheets/data import sheets/peer.{ CompanionInitiative, DiceRoll, Encounter, InitiativeEntry, PlayerInitiative, RuleRestrictions, Snapshot, SnapshotAbilities, SnapshotChoice, - SnapshotEquipment, SnapshotFeat, SnapshotFeature, SnapshotSkill, SnapshotSpell, + SnapshotEquipment, SnapshotFeat, SnapshotFeature, SnapshotResource, + SnapshotSkill, SnapshotSpell, } pub fn main() -> Nil { @@ -102,6 +103,12 @@ pub fn complete_character_snapshot_round_trip_test() { hit_die: 10, spell_save_dc: 12, spellcasting_ability: "Intelligence", + resources: [ + SnapshotResource(name: "Second Wind", current: 1, maximum: 2), + ], + concentration: "Shield of Faith", + inspiration: True, + conditions: ["Poisoned"], ) assert peer.parse_snapshot(peer.snapshot_to_json(snapshot)) == Ok(snapshot)