Compare commits
7 Commits
ce1f0fee41
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b868d3d2ec | |||
| 2063fffa29 | |||
| bdd7d03d9c | |||
| 6d7629794e | |||
| dbf3126f29 | |||
| c9252cfe66 | |||
| b2bbad9c58 |
4
Justfile
4
Justfile
@@ -22,6 +22,10 @@ build:
|
|||||||
deploy: build
|
deploy: build
|
||||||
scp -r dist/* {{ user }}@{{ host }}:{{ www-root }}/
|
scp -r dist/* {{ user }}@{{ host }}:{{ www-root }}/
|
||||||
|
|
||||||
|
# Deploy to chimaera.malzahn.lan, skipping the books/ directory.
|
||||||
|
deploy-chimaera: build
|
||||||
|
rsync -av --exclude='books/' dist/ admin@chimaera.malzahn.lan:/home/admin/caddy/public_html/fabula/
|
||||||
|
|
||||||
# Build the share service as a static binary for the deploy host.
|
# Build the share service as a static binary for the deploy host.
|
||||||
build-server:
|
build-server:
|
||||||
cd server && CGO_ENABLED=0 GOOS=linux GOARCH={{ go-arch }} go build -o share-svc .
|
cd server && CGO_ENABLED=0 GOOS=linux GOARCH={{ go-arch }} go build -o share-svc .
|
||||||
|
|||||||
383
data/skills.yml
Normal file
383
data/skills.yml
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
skills:
|
||||||
|
- name: Arcane Circle
|
||||||
|
class: arcanist
|
||||||
|
max_level: 4
|
||||||
|
description: "After you willingly dismiss an Arcanum on your turn during a conflict (see next page), if that Arcanum had not been summoned during this same turn and you have an arcane weapon equipped, you may immediately perform the Spell action for free. The spell you cast this way must have a total Mind Point cost of【 SL × 5】 or lower (you must still pay the spell's MP cost)."
|
||||||
|
|
||||||
|
- name: "Arcane Regeneration"
|
||||||
|
class: arcanist
|
||||||
|
max_level: 2
|
||||||
|
description: "When you summon an Arcanum, you immediately recover 【SL × 5】 Hit Points."
|
||||||
|
|
||||||
|
- name: Bind and Summon
|
||||||
|
class: arcanist
|
||||||
|
max_level: 1
|
||||||
|
description: |
|
||||||
|
You may bind Arcana to your soul and summon them later. The Game Master will
|
||||||
|
tell you the details of each binding process when you first encounter the Arcanum in
|
||||||
|
question.
|
||||||
|
You may use an action and spend 40 Mind Points to summon an Arcanum you have
|
||||||
|
bound: the details of this process are explained on the next page.
|
||||||
|
If you take this Skill at character creation, you begin play with one Arcanum of your
|
||||||
|
choice already bound to you, chosen from the list on the next pages. Other than that,
|
||||||
|
you may only obtain new Arcana through exploration and story progression.
|
||||||
|
|
||||||
|
- name: Emergency Arcanum
|
||||||
|
class: arcanist
|
||||||
|
max_level: 6
|
||||||
|
description: "As long as you are in Crisis, the cost for summoning your Arcana is reduced by【 SL × 5】 Mind Points."
|
||||||
|
|
||||||
|
- name: Ritual Arcanism
|
||||||
|
class: arcanist
|
||||||
|
max_level: 1
|
||||||
|
description: "You may perform Rituals of the Arcanism discipline, as long as their effects fall within the domains of one or more Arcana you have bound (see next pages). Arcanism Rituals use【 WLP + WLP】 for the Magic Check."
|
||||||
|
|
||||||
|
- name: "Consume"
|
||||||
|
class: chimerist
|
||||||
|
max_level: 5
|
||||||
|
description: "After you deal damage to one or more creatures with a spell, if you have an arcane, dagger or flail weapon equipped, you recove【r SL × 2】 Mind Points."
|
||||||
|
|
||||||
|
- name: "Feral Speech"
|
||||||
|
class: chimerist
|
||||||
|
max_level: 1
|
||||||
|
description: "You can communicate with creatures of the beast, monster and plant Species."
|
||||||
|
|
||||||
|
- name: "Pathogenesis"
|
||||||
|
class: chimerist
|
||||||
|
max_level: 1
|
||||||
|
description: "When you deal damage to one or more creatures with one of your Chimerist spells, each of those creatures that share their Species with the creature you originally learned that spell from suffers poisoned."
|
||||||
|
|
||||||
|
- name: "Ritual Chimerism"
|
||||||
|
class: chimerist
|
||||||
|
max_level: 1
|
||||||
|
description: "You may perform Rituals whose effects fall within the Chimerism discipline. When you acquire this Skill, choose【 INS + WLP】 o【r MIG + WLP】. From now on, your Chimerism Rituals will use the chosen Attributes for the Magic Check."
|
||||||
|
|
||||||
|
- name: "Spell Mimic"
|
||||||
|
class: chimerist
|
||||||
|
max_level: 10
|
||||||
|
description: "When you see a creature belonging to the beast, monster or plant Species cast a spell, you may immediately choose to learn that spell as a Chimerist spell of your own: if you do, record the Species of the creature you learned it from. When you first acquire this Skill, choose【 INS + WLP】 or【 MIG + WLP】. From now on, your offensive (r) Chimerist spells will use the chosen Attributes for the Magic Check, regardless of the Attributes used by the creature you learned the spell from. You may have up to【 SL + 2】 different Chimerist spells memorized this way. If you want to memorize a new Chimerist spell but are already at your limit, you must forget one of your old spells and replace it with the new spell."
|
||||||
|
|
||||||
|
- name: Agony
|
||||||
|
class: darkblade
|
||||||
|
max_level: 5
|
||||||
|
description: "After you deal damage to one or more creatures, if you have a Bond towards at least one of those creatures, you may recove【r SL × 2】 Hit Points and【 SL × 2】 Mind Points."
|
||||||
|
|
||||||
|
- name: Dark Blood
|
||||||
|
class: darkblade
|
||||||
|
max_level: 1
|
||||||
|
description: "As long as you are in Crisis, you have Resistance to dark damage and poison damage."
|
||||||
|
|
||||||
|
- name: Heart of Darkness
|
||||||
|
class: darkblade
|
||||||
|
max_level: 1
|
||||||
|
description: "Once per scene upon entering Crisis, you may choose a specific creature you can see that you don't have a Bond towards. If you do, create a Bond of hatred towards that creature."
|
||||||
|
|
||||||
|
- name: Painful Lesson
|
||||||
|
class: darkblade
|
||||||
|
max_level: 3
|
||||||
|
description: "After another creature causes you to lose Hit Points (with an attack, a spell or any other method), you may immediately perform the Study action on that creature (see page 74) for free. If you do, gain a bonus equal to【 SL】 to your Check. Remember, you can study the same aspect of a creature only once."
|
||||||
|
|
||||||
|
- name: Shadow Strike
|
||||||
|
class: darkblade
|
||||||
|
max_level: 5
|
||||||
|
description: "You have learned to channel your vital force into your attacks. You may use an action to perform a Shadow Strike: roll your current Might die and lose an amount of Hit Points equal to【 the number rolled on your Might die】. If this didn't reduce your Hit Points to 0, you may perform a free attack with a weapon you have equipped: if this attack hits one or more targets, it deals extra damage equal to 【SL + the number rolled on your Might die】. However, all damage dealt by this attack becomes dark and its damage type cannot be changed."
|
||||||
|
|
||||||
|
- name: "Cataclysm"
|
||||||
|
class: elementalist
|
||||||
|
max_level: 3
|
||||||
|
description: "When you cast an instantaneous spell, if you have an arcane weapon equipped, you may increase the spell's total MP cost by up to【 SL × 10】 Mind Points. If you do so and the spell deals damage to one or more creatures, it will deal 5 extra damage to each creature for every 10 Mind Points by which you increased its total MP cost."
|
||||||
|
|
||||||
|
- name: "Elemental Magic"
|
||||||
|
class: elementalist
|
||||||
|
max_level: 10
|
||||||
|
description: "Each time you acquire this Skill, learn one Elementalist spell (see next two pages). Offensive (r) Elementalist spells use【 INS + WLP】 for the Magic Check."
|
||||||
|
|
||||||
|
- name: "Magical Artillery"
|
||||||
|
class: elementalist
|
||||||
|
max_level: 3
|
||||||
|
description: "When you cast an offensive (r) spell, if you have an arcane weapon equipped, you gain a bonus to your Magic Check equal to【 SL × 2】."
|
||||||
|
|
||||||
|
- name: "Ritual Elementalism"
|
||||||
|
class: elementalist
|
||||||
|
max_level: 1
|
||||||
|
description: "You may perform Rituals whose effects fall within the Elementalism discipline. Elementalism Rituals use【 INS + WLP】 for the Magic Check."
|
||||||
|
|
||||||
|
- name: "Spellblade"
|
||||||
|
class: elementalist
|
||||||
|
max_level: 4
|
||||||
|
description: "When you cast an offensive (r) spell targeting a single creature, if the spell has a total Mind Point cost of 【 SL × 10】 or lower and you have one or more bow, brawling, dagger, flail, spear or sword weapons equipped, you may choose one of those weapons. If you do, your Magic Check for the spell will use the chosen weapon's Accuracy Check formula; for instance, the Magic Check for an Elementalist spell cast through a bronze sword (page 131) will be d【 DEX + MIG】 +1 instead of 【 INS + WLP】."
|
||||||
|
|
||||||
|
- name: Absorb MP
|
||||||
|
class: entropist
|
||||||
|
max_level: 5
|
||||||
|
description: "After you suffer damage, you may immediately recove【r SL × 2】 Mind Points."
|
||||||
|
|
||||||
|
- name: Entropic Magic
|
||||||
|
class: entropist
|
||||||
|
max_level: 10
|
||||||
|
description: "Each time you acquire this Skill, learn one Entropist spell (see next two pages). Offensive (r) Entropist spells use【 INS + WLP】 for the Magic Check."
|
||||||
|
|
||||||
|
- name: Lucky Seven
|
||||||
|
class: entropist
|
||||||
|
max_level: 1
|
||||||
|
description: "You have a lucky number; at the beginning of each session, that number is 7. Once per scene after you perform a Check, you may replace the value shown on one of the dice you rolled with your lucky number (even if this would give an impossible Result, such as a value of 7 on a d6). If you do, the replaced value becomes your new lucky number."
|
||||||
|
|
||||||
|
- name: Ritual Entropism
|
||||||
|
class: entropist
|
||||||
|
max_level: 1
|
||||||
|
description: "You may perform Rituals whose effects fall within the Entropism discipline. Entropism Rituals use【 INS + WLP】 for the Magic Check."
|
||||||
|
|
||||||
|
- name: Stolen Time
|
||||||
|
class: entropist
|
||||||
|
max_level: 4
|
||||||
|
description: "During a conflict, you may use an action to interfere with the flow of time by spending up to【 SL × 5】 Mind Points. For every 5 Mind Points you spend this way, choose one option: one creature you can see suffers slow; or one creature you can see recovers from slow; or one creature you can see may immediately perform the Equipment action for free; or choose one ally you can see who has yet to take a turn during this round: that ally may take their turn immediately after yours during this round. Each option can only be chosen once per use of this Skill."
|
||||||
|
|
||||||
|
- name: Adrenaline
|
||||||
|
class: fury
|
||||||
|
max_level: 5
|
||||||
|
description: "As long as you are in Crisis, you dea【l SL × 2】 extra damage (be it with attacks, spells, Arcana, items or any other method)."
|
||||||
|
|
||||||
|
- name: Frenzy
|
||||||
|
class: fury
|
||||||
|
max_level: 1
|
||||||
|
description: "Your Accuracy Checks with brawling, dagger, flail and thrown weapons trigger a critical success if both dice show the same number (and the Check is not a fumble)."
|
||||||
|
|
||||||
|
- name: Indomitable Spirit
|
||||||
|
class: fury
|
||||||
|
max_level: 4
|
||||||
|
description: "When you spend one or more Fabula Points, you get an additional benefit — choose one option: you recover【 SL × 5】 Hit Points; or you recover【 SL × 5】 Mind Points; or you recover from a single status effect of your choice."
|
||||||
|
|
||||||
|
- name: Provoke
|
||||||
|
class: fury
|
||||||
|
max_level: 5
|
||||||
|
description: "You may use an action and spend 5 Mind Points to perform an Opposed【 MIG + WLP】 Check against a creature you can see — describe how you taunt them! If you succeed, the target suffers enraged and is compelled to focus their attention on you (their attacks and offensive spells must include you among the targets if possible). This compulsion ends if you fall unconscious or leave the scene, if the creature is no longer enraged, or if they are successfully provoked by someone else. You gain a bonus equal to【 SL】 to you【r MIG + WLP】 Checks for this Skill."
|
||||||
|
|
||||||
|
- name: Withstand
|
||||||
|
class: fury
|
||||||
|
max_level: 4
|
||||||
|
description: "When you perform the Guard action, if you choose not to provide cover to another creature, you recover Hit Points equal to 【SL, multiplied by the highest strength among your Bonds】 and choose Might or Willpower: you treat the chosen Attribute as being one die size higher (up to a maximum of d12) until the end of your next turn."
|
||||||
|
|
||||||
|
- name: Bodyguard
|
||||||
|
class: guardian
|
||||||
|
max_level: 1
|
||||||
|
description: "If you perform the Guard action and choose to provide cover to another creature, that creature gains Resistance to all damage types until the start of your next turn."
|
||||||
|
|
||||||
|
- name: Defensive Mastery
|
||||||
|
class: guardian
|
||||||
|
max_level: 5
|
||||||
|
description: "As long as you have a shield or a martial armor equipped, all damage you suffer is reduced by【 SL】 (applied before damage Affinities)."
|
||||||
|
|
||||||
|
- name: Dual Shieldbearer
|
||||||
|
class: guardian
|
||||||
|
max_level: 1
|
||||||
|
description: "You may now equip a shield in your main hand slot. As long as you have two shields equipped, you gain the benefits of both items and may treat them as a combined two-handed melee brawling weapon (see book)."
|
||||||
|
|
||||||
|
- name: Fortress
|
||||||
|
class: guardian
|
||||||
|
max_level: 5
|
||||||
|
description: "Permanently increase your maximum Hit Points by【 SL × 3】."
|
||||||
|
|
||||||
|
- name: Protect
|
||||||
|
class: guardian
|
||||||
|
max_level: 1
|
||||||
|
description: "When another creature is threatened by an attack, spell or other danger, you may take their place (any Checks that are part of the danger will be performed against you; you may declare the use of this Skill before or after the Checks have been made). If the danger already affected you, it affects you twice (resolve both instances separately); you also cannot protect multiple creatures from the same danger. If you use this Skill during a conflict, you cannot use it again until the start of your next turn."
|
||||||
|
|
||||||
|
- name: Flash of Insight
|
||||||
|
class: loremaster
|
||||||
|
max_level: 3
|
||||||
|
description: "When you roll a 13 or higher on a Check performed to investigate a creature, item or location — this includes using the Study action during a conflict — you may ask the Game Master up to【 SL】 questions concerning the subject of your investigation. You may ask these questions immediately or save them for later; whenever you ask one of these questions, the Game Master will answer truthfully and you will describe your character's deductive process. This Skill may only be used once on the same creature, item or location."
|
||||||
|
|
||||||
|
- name: Focused
|
||||||
|
class: loremaster
|
||||||
|
max_level: 5
|
||||||
|
description: "Permanently increase your maximum Mind Points by【 SL × 3】. When you perform an Open Check using【 INS + INS】, you gain a bonus equal to【 SL】 on that Check (this only applies to Open Checks)."
|
||||||
|
|
||||||
|
- name: Knowledge is Power
|
||||||
|
class: loremaster
|
||||||
|
max_level: 1
|
||||||
|
description: "When you perform an Accuracy Check, you may replace one of the Attribute dice with Insight (such as【 INS + INS】 for a pistol o【r INS + MIG】 for a waraxe)."
|
||||||
|
|
||||||
|
- name: Quick Assessment
|
||||||
|
class: loremaster
|
||||||
|
max_level: 6
|
||||||
|
description: "At the start of a conflict, you may spend up to【 SL × 5】 Mind Points. For every 5 Mind Points you spend this way, choose one option: choose a creature you can see and the GM reveals one of their Traits; or name a damage type and choose a creature you can see, and the GM reveals that creature's Affinity towards that damage type."
|
||||||
|
|
||||||
|
- name: Trained Memory
|
||||||
|
class: loremaster
|
||||||
|
max_level: 1
|
||||||
|
description: "You may perfectly recall the details of any scene you have visited within the past week. You can 'go back in time' within your mind in order to examine and investigate such scenes again — your Flash of Insight Skill will apply to these memories as well."
|
||||||
|
|
||||||
|
- name: Condemn
|
||||||
|
class: orator
|
||||||
|
max_level: 4
|
||||||
|
description: "You may use an action and spend 5 Mind Points to perform an Opposed【 INS + WLP】 Check against a creature that can hear and understand you — describe your accusations! If you succeed, the target loses【 SL × 10】 Mind Points and suffers dazed or shaken (your choice). You gain a bonus equal to【 SL】 to you【r INS + WLP】 Checks for this Skill."
|
||||||
|
|
||||||
|
- name: Encourage
|
||||||
|
class: orator
|
||||||
|
max_level: 6
|
||||||
|
description: "During a conflict, you may use an action and spend 5 Mind Points to choose another creature that can hear and understand you. That creature recovers【 SL × 5】 Hit Points and chooses Dexterity, Insight, Might, or Willpower: they treat the chosen Attribute as being one die size higher (up to a maximum of d12) until the start of your next turn."
|
||||||
|
|
||||||
|
- name: My Trust in You
|
||||||
|
class: orator
|
||||||
|
max_level: 2
|
||||||
|
description: "After another Player Character who is able to hear you performs a Check, you may spend 1 Fabula Point and invoke one of their Traits or Bonds in order to let them reroll dice or improve the Result of the Check (following the normal rules). Then, if you have a Bond towards that character, they recove【r SL × 10】 Mind Points."
|
||||||
|
|
||||||
|
- name: Persuasive
|
||||||
|
class: orator
|
||||||
|
max_level: 2
|
||||||
|
description: "When you successfully perform a Check to fill or erase sections of a Clock, if your approach relied on charm, diplomacy, deception or intimidation, you may spend up to 【SL × 20】 Mind Points. If you do, fill or erase an additional section of that Clock for every 20 Mind Points you spend this way."
|
||||||
|
|
||||||
|
- name: Unexpected Ally
|
||||||
|
class: orator
|
||||||
|
max_level: 1
|
||||||
|
description: "You may use an action and spend 1 Fabula Point to choose a non-hostile creature able to hear and understand you. If you do, that creature becomes helpful towards you so long as you are kind and respectful to them and your requests are reasonable."
|
||||||
|
|
||||||
|
- name: Cheap Shot
|
||||||
|
class: rogue
|
||||||
|
max_level: 5
|
||||||
|
description: "When you hit a creature with an attack, if the attack only targeted that creature and they are suffering from one or more status effects, you may have it deal extra damage equal to【 SL + the number of status effects on the creature】"
|
||||||
|
|
||||||
|
- name: Dodge
|
||||||
|
class: rogue
|
||||||
|
max_level: 3
|
||||||
|
description: "As long as you have no shields and no martial armor equipped, your Defense score is increased by【 SL】"
|
||||||
|
|
||||||
|
- name: High Speed
|
||||||
|
class: rogue
|
||||||
|
max_level: 3
|
||||||
|
description: "At the start of a conflict, you may spend 10 Mind Points. If you do, choose one option and apply it before the start of the first round: perform a free attack with a weapon you have equipped; or perform a Hinder or Objective action. You also gain a bonus equal to【 SL】 to all Checks you perform as part of the chosen option."
|
||||||
|
|
||||||
|
- name: See You Later
|
||||||
|
class: rogue
|
||||||
|
max_level: 1
|
||||||
|
description: "You may use an action and spend 1 Fabula Point to vanish from the current scene, reappearing whenever you want during a different scene in which another Player Character is present. Describe how you escaped and miraculously got here!"
|
||||||
|
|
||||||
|
- name: Soul Steal
|
||||||
|
class: rogue
|
||||||
|
max_level: 5
|
||||||
|
description: "You may use an action to perform a【 DEX + WLP】 Check against the Magic Defense of a creature you can see. If you succeed and the target is a soldier, you recover【 SL】 Inventory Points; if they are an elite or champion, the GM gives you the target’s soul treasure, an item worth an amount of zenit equal to or lower than【 the target's level multiplied by 30, or by 50 if they are a Villain】. This soul treasure will appear inside your backpack; a creature can be successfully stolen from with this Skill only once. You gain a bonus equal to【 SL】 to you【r DEX + WLP】 Checks for this Skill."
|
||||||
|
|
||||||
|
- name: Barrage
|
||||||
|
class: sharpshooter
|
||||||
|
max_level: 1
|
||||||
|
description: "When you perform a ranged attack, you may spend 10 Mind Points to choose one option: the attack gains multi (2); or you increase the attack's multi property by one, up to a maximum of multi (3)."
|
||||||
|
|
||||||
|
- name: Crossfire
|
||||||
|
class: sharpshooter
|
||||||
|
max_level: 1
|
||||||
|
description: "After a creature you can see performs a ranged attack, you may spend an amount of Mind Points equal to the total Result of their Accuracy Check in order to have the attack fail automatically against all targets. You can only use this Skill if you have a ranged weapon equipped, and it has no effect if the Accuracy Check was a critical success."
|
||||||
|
|
||||||
|
- name: Hawkeye
|
||||||
|
class: sharpshooter
|
||||||
|
max_level: 5
|
||||||
|
description: "When you perform the Guard action, if you choose not to provide cover to another creature, you may choose one option: the next ranged attack you perform before the end of the current scene will deal【 SL × 2】 extra damage; or you may immediately perform a free attack with a bow or firearm you have equipped, treating your High Roll (HR) as 0 when calculating damage dealt by this attack."
|
||||||
|
|
||||||
|
- name: Ranged Weapon Mastery
|
||||||
|
class: sharpshooter
|
||||||
|
max_level: 4
|
||||||
|
description: "You gain a bonus equal to【 SL】 to all Accuracy Checks with ranged weapons."
|
||||||
|
|
||||||
|
- name: Warning Shot
|
||||||
|
class: sharpshooter
|
||||||
|
max_level: 4
|
||||||
|
description: "When you hit one or more targets with a ranged attack that would deal damage, you may have the attack deal no damage. If you do, choose one option: inflict shaken on each target hit by the attack; or inflict slow on each target hit by the attack; or each target hit by the attack loses【 SL × 10】 Mind Points. Describe your maneuver!"
|
||||||
|
|
||||||
|
- name: Healing Power
|
||||||
|
class: spiritist
|
||||||
|
max_level: 2
|
||||||
|
description: "When you cast a spell that targets one or more allies, if you have an arcane weapon equipped, you may have each of those allies recover an amount of Hit Points equal to 【SL, multiplied by the number of Bonds you have】. This healing is separate from any healing caused by the effects of the spell."
|
||||||
|
|
||||||
|
- name: Ritual Spiritism
|
||||||
|
class: spiritist
|
||||||
|
max_level: 1
|
||||||
|
description: "You may perform Rituals whose effects fall within the Spiritism discipline. Spiritism Rituals use【 INS + WLP】 for the Magic Check."
|
||||||
|
|
||||||
|
- name: Spiritual Magic
|
||||||
|
class: spiritist
|
||||||
|
max_level: 10
|
||||||
|
description: "Each time you acquire this Skill, learn one Spiritist spell (see next two pages). Offensive (r) Spiritist spells use【 INS + WLP】 for the Magic Check."
|
||||||
|
|
||||||
|
- name: Support Magic
|
||||||
|
class: spiritist
|
||||||
|
max_level: 1
|
||||||
|
description: "When you cast a spell that targets one or more allies, if you have an arcane weapon equipped, you may choose one of those allies you have a Bond towards. If you do, that ally gains a bonus to the next Check they perform during the current scene; this bonus is equal to the strength of your Bond towards them."
|
||||||
|
|
||||||
|
- name: Vismagus
|
||||||
|
class: spiritist
|
||||||
|
max_level: 1
|
||||||
|
description: "When you cast a spell, if you don't have enough Mind Points to pay for its total cost, you may choose to spend twice as many Hit Points instead. You cannot use this Skill if doing so would reduce you to 0 Hit Points. If a spell cast this way would cause you to recover Hit Points, you instead recover no Hit Points (the spell functions normally on any other target)."
|
||||||
|
|
||||||
|
- name: Emergency Item
|
||||||
|
class: tinkerer
|
||||||
|
max_level: 1
|
||||||
|
description: "Once per conflict scene, if you are in Crisis, you may perform an additional action on your turn. This action must be the Inventory action."
|
||||||
|
|
||||||
|
- name: Gadgets
|
||||||
|
class: tinkerer
|
||||||
|
max_level: 5
|
||||||
|
description: "When you first acquire this Skill, choose a gadget type: alchemy, infusions or magitech (see next four pages). You gain its basic benefits. Whenever you take this Skill again, choose one option: you gain the basic benefits of a new gadget type; or you gain the advanced benefits of a gadget type whose basic benefits you already obtained; or you gain the superior benefits of a gadget type whose advanced benefits you already obtained."
|
||||||
|
|
||||||
|
- name: Potion Rain
|
||||||
|
class: tinkerer
|
||||||
|
max_level: 2
|
||||||
|
description: "When you create a potion that restores a single creature's HP and/or MP, you may have it affect up to【 SL】 additional creatures. If you do, the potion only restores half the normal amount of HP and MP to each creature."
|
||||||
|
|
||||||
|
- name: Secret Formula
|
||||||
|
class: tinkerer
|
||||||
|
max_level: 5
|
||||||
|
description: "When you create a potion or magisphere whose effects restore HP and/or MP, each restored amount is increased by【 SL × 5】. When you create an elemental shard, potion or magisphere that deals damage, that item deals【 SL】 extra damage."
|
||||||
|
|
||||||
|
- name: Visionary
|
||||||
|
class: tinkerer
|
||||||
|
max_level: 5
|
||||||
|
description: "When you work on a Project, up to【 SL × 100】 zenit of material costs are automatically paid; additionally, you generate an additional 【SL】 progress every day. If multiple characters with this Skill work on the same Project, the effects will be cumulative."
|
||||||
|
|
||||||
|
- name: Faithful Companion
|
||||||
|
class: wayfarer
|
||||||
|
max_level: 5
|
||||||
|
description: "Together with the rest of your group, design a level 5 beast, construct, elemental or plant creature (see page 302) that becomes your companion. This creature has no Initiative score and does not level up, can have up to two basic attacks, gains a bonus equal to【 SL】 to Accuracy Checks and Magic Checks, and their maximum Hit Points are equal to【 (SL multiplied by the companion's base Might die size) + half your level】. Your companion doesn't get a turn during conflicts, but on your turn you can use an action to have the companion perform an action (only once per turn). If you leave a scene, your companion leaves with you. If your companion falls to 0 Hit Points, they flee and rejoin you at the start of the next scene in which you are present, with HP equal to their Crisis score. When you rest, your companion also gains the full benefits of resting."
|
||||||
|
|
||||||
|
- name: Resourceful
|
||||||
|
class: wayfarer
|
||||||
|
max_level: 4
|
||||||
|
description: "You recove【r SL】 Inventory Points after each travel roll (see page 106)."
|
||||||
|
|
||||||
|
- name: Tavern Talk
|
||||||
|
class: wayfarer
|
||||||
|
max_level: 3
|
||||||
|
description: "When you rest inside an inn or tavern, you may ask the Game Master up to 【SL】 questions about your surroundings and the people who live here; the Game Master will answer truthfully and you describe how you gathered the information."
|
||||||
|
|
||||||
|
- name: Treasure Hunter
|
||||||
|
class: wayfarer
|
||||||
|
max_level: 2
|
||||||
|
description: "When your group journeys on the world map, you will make a discovery on a roll of 【SL + 1】 or lower on the travel roll (instead of only on a 1)."
|
||||||
|
|
||||||
|
- name: Well-Traveled
|
||||||
|
class: wayfarer
|
||||||
|
max_level: 1
|
||||||
|
description: "You reduce the die rolled for your travel rolls by one size (to a minimum of d6). If multiple characters have this Skill, the effects are not cumulative."
|
||||||
|
|
||||||
|
- name: Bladestorm
|
||||||
|
class: weaponmaster
|
||||||
|
max_level: 1
|
||||||
|
description: "When you perform a melee attack, you may spend 10 Mind Points to choose one option: the attack gains multi (2); or you increase the attack's multi property by one, up to a maximum of multi (3)."
|
||||||
|
|
||||||
|
- name: Bone Crusher
|
||||||
|
class: weaponmaster
|
||||||
|
max_level: 1
|
||||||
|
description: "When you hit one or more targets with a melee attack that would deal damage, you may have the attack deal no damage. If you do, choose one option: inflict dazed on each target hit by the attack; or inflict weak on each target hit by the attack; or each target hit by the attack loses【 SL × 10】 Mind Points. Describe your maneuver!"
|
||||||
|
|
||||||
|
- name: Breach
|
||||||
|
class: weaponmaster
|
||||||
|
max_level: 1
|
||||||
|
description: "You may use an action and spend 5 Mind Points to perform a free attack with a melee weapon you have equipped. This attack must target a single creature. If the attack is successful, it deals no damage and you choose one option: you destroy one shield equipped by the target; or you destroy the target's equipped armor; or whenever the target suffers damage from a source before the start of your next turn, that source deals【 SL × 2】 extra damage to them."
|
||||||
|
|
||||||
|
- name: Counterattack
|
||||||
|
class: weaponmaster
|
||||||
|
max_level: 1
|
||||||
|
description: "After an enemy hits or misses you with a melee attack, if the Result of their Accuracy Check was an even number, you may perform a free attack against that enemy (after their attack has been fully resolved). This attack must be a melee attack and must have that enemy as its only target; treat your High Roll (HR) as 0 when calculating damage dealt by this attack."
|
||||||
|
|
||||||
|
- name: Melee Weapon Mastery
|
||||||
|
class: weaponmaster
|
||||||
|
max_level: 1
|
||||||
|
description: "You gain a bonus equal to【 SL】 to all Accuracy Checks with melee weapons."
|
||||||
@@ -10,6 +10,6 @@ pkgs.mkShellNoCC {
|
|||||||
typescript-language-server
|
typescript-language-server
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
eval "$(starship init bash)"
|
which -s starship >/dev/null 2>&1 && eval "$(starship init bash)"
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
346
src/components/ClassesPage.tsx
Normal file
346
src/components/ClassesPage.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import skillsFile from "../../data/skills.yml";
|
||||||
|
import { ClassEntry } from "../types";
|
||||||
|
import { autoResize } from "../utils";
|
||||||
|
|
||||||
|
type SkillPickerTarget = { list: "primary" | "other"; idx: number };
|
||||||
|
|
||||||
|
function formatSkillLine(t: SkillTemplate): string {
|
||||||
|
return `• ${t.name} (${t.class}, max SL ${t.max_level}): ${t.description}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassesPageProps {
|
||||||
|
isActive: boolean;
|
||||||
|
primaryClasses: ClassEntry[];
|
||||||
|
setPrimaryClasses: React.Dispatch<React.SetStateAction<ClassEntry[]>>;
|
||||||
|
otherClasses: ClassEntry[];
|
||||||
|
setOtherClasses: React.Dispatch<React.SetStateAction<ClassEntry[]>>;
|
||||||
|
heroicSkills: string;
|
||||||
|
onHeroicSkillsChange: (v: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClassesPage({
|
||||||
|
isActive,
|
||||||
|
primaryClasses,
|
||||||
|
setPrimaryClasses,
|
||||||
|
otherClasses,
|
||||||
|
setOtherClasses,
|
||||||
|
heroicSkills,
|
||||||
|
onHeroicSkillsChange,
|
||||||
|
}: ClassesPageProps) {
|
||||||
|
const [skillPickerTarget, setSkillPickerTarget] = useState<SkillPickerTarget | null>(null);
|
||||||
|
const [skillCategory, setSkillCategory] = useState<string>("all");
|
||||||
|
|
||||||
|
const allSkills = (skillsFile as SkillsFile).skills;
|
||||||
|
const skillCategories = ["all", ...Array.from(new Set(allSkills.map((s) => s.class))).sort()];
|
||||||
|
const visibleSkills =
|
||||||
|
skillCategory === "all" ? allSkills : allSkills.filter((s) => s.class === skillCategory);
|
||||||
|
|
||||||
|
const appendSkill = (target: SkillPickerTarget, line: string) => {
|
||||||
|
const setter = target.list === "primary" ? setPrimaryClasses : setOtherClasses;
|
||||||
|
setter((prev) =>
|
||||||
|
prev.map((c, i) =>
|
||||||
|
i === target.idx ? { ...c, skills: c.skills ? c.skills + "\n" + line : line } : c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSkillPicker = (target: SkillPickerTarget, className: string) => {
|
||||||
|
const match = allSkills.find((s) => s.class.toLowerCase() === className.trim().toLowerCase());
|
||||||
|
setSkillCategory(match ? match.class : "all");
|
||||||
|
setSkillPickerTarget(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`page${isActive ? " active" : ""}`} id="page-classes">
|
||||||
|
<div className="grid-2" style={{ marginBottom: 20 }}>
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">✦</span> Primary Classes (up to 3)
|
||||||
|
</div>
|
||||||
|
{primaryClasses.map((cls, idx) => (
|
||||||
|
<div key={idx} className="class-block">
|
||||||
|
<div className="class-header">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Class name…"
|
||||||
|
value={cls.name || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPrimaryClasses((prev) =>
|
||||||
|
prev.map((c, i) => (i === idx ? { ...c, name: e.target.value } : c))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Free benefits…"
|
||||||
|
value={cls.benefits || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPrimaryClasses((prev) =>
|
||||||
|
prev.map((c, i) => (i === idx ? { ...c, benefits: e.target.value } : c))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="class-skills">
|
||||||
|
<textarea
|
||||||
|
placeholder="Skill information…"
|
||||||
|
value={cls.skills || ""}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) autoResize(el);
|
||||||
|
}}
|
||||||
|
onInput={(e) => autoResize(e.currentTarget)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPrimaryClasses((prev) =>
|
||||||
|
prev.map((c, i) => (i === idx ? { ...c, skills: e.target.value } : c))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "6px", fontSize: "0.85em" }}
|
||||||
|
>
|
||||||
|
Level
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={cls.level ?? 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPrimaryClasses((prev) =>
|
||||||
|
prev.map((c, i) =>
|
||||||
|
i === idx ? { ...c, level: Number(e.target.value) } : c
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{ width: "50px" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="add-btn"
|
||||||
|
style={{ marginTop: 0 }}
|
||||||
|
onClick={() => openSkillPicker({ list: "primary", idx }, cls.name)}
|
||||||
|
>
|
||||||
|
+ Add Template Skill
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="spell-del-btn"
|
||||||
|
onClick={() =>
|
||||||
|
setPrimaryClasses((prev) => prev.filter((_, i) => i !== idx))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
✕ Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="add-btn"
|
||||||
|
disabled={primaryClasses.length >= 3}
|
||||||
|
onClick={() =>
|
||||||
|
setPrimaryClasses((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ name: "", level: 1, benefits: "", skills: "" },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Add Primary Class
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">◎</span> Other Classes (max 3 non-mastered)
|
||||||
|
</div>
|
||||||
|
{otherClasses.map((cls, idx) => (
|
||||||
|
<div key={idx} className="class-block">
|
||||||
|
<div className="class-header">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Class name…"
|
||||||
|
value={cls.name || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOtherClasses((prev) =>
|
||||||
|
prev.map((c, i) => (i === idx ? { ...c, name: e.target.value } : c))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Free benefits…"
|
||||||
|
value={cls.benefits || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOtherClasses((prev) =>
|
||||||
|
prev.map((c, i) => (i === idx ? { ...c, benefits: e.target.value } : c))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="class-skills">
|
||||||
|
<textarea
|
||||||
|
placeholder="Skill information…"
|
||||||
|
value={cls.skills || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOtherClasses((prev) =>
|
||||||
|
prev.map((c, i) => (i === idx ? { ...c, skills: e.target.value } : c))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "6px", fontSize: "0.85em" }}
|
||||||
|
>
|
||||||
|
Level
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={cls.level ?? 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOtherClasses((prev) =>
|
||||||
|
prev.map((c, i) =>
|
||||||
|
i === idx ? { ...c, level: Number(e.target.value) } : c
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{ width: "50px" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="add-btn"
|
||||||
|
style={{ marginTop: 0 }}
|
||||||
|
onClick={() => openSkillPicker({ list: "other", idx }, cls.name)}
|
||||||
|
>
|
||||||
|
+ Add Template Skill
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="spell-del-btn"
|
||||||
|
onClick={() =>
|
||||||
|
setOtherClasses((prev) => prev.filter((_, i) => i !== idx))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
✕ Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="add-btn"
|
||||||
|
onClick={() =>
|
||||||
|
setOtherClasses((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ name: "", level: 1, benefits: "", skills: "" },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Add Class
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">★</span> Heroic Skills
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={heroicSkills}
|
||||||
|
onChange={(e) => onHeroicSkillsChange(e.target.value)}
|
||||||
|
placeholder="Record your heroic skill abilities here…"
|
||||||
|
style={{ minHeight: 100 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{skillPickerTarget &&
|
||||||
|
(() => {
|
||||||
|
const target = skillPickerTarget;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="spell-picker-overlay"
|
||||||
|
onClick={() => setSkillPickerTarget(null)}
|
||||||
|
>
|
||||||
|
<div className="spell-picker-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="spell-picker-header">
|
||||||
|
<span>Choose a skill</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="spell-picker-close"
|
||||||
|
onClick={() => setSkillPickerTarget(null)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 6,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
padding: "8px 14px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skillCategories.map((cat) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={cat}
|
||||||
|
className={`add-btn${skillCategory === cat ? " active-filter" : ""}`}
|
||||||
|
style={{
|
||||||
|
padding: "2px 8px",
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
textTransform: "capitalize",
|
||||||
|
}}
|
||||||
|
onClick={() => setSkillCategory(cat)}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ul className="spell-picker-list">
|
||||||
|
{visibleSkills.map((t, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="spell-picker-item"
|
||||||
|
onClick={() => {
|
||||||
|
appendSkill(target, formatSkillLine(t));
|
||||||
|
setSkillPickerTarget(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="spell-picker-name">{t.name}</span>
|
||||||
|
<span className="spell-picker-class">{t.class}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/Header.tsx
Normal file
51
src/components/Header.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const TABS = ["main", "classes", "spells", "manage"] as const;
|
||||||
|
const TAB_LABELS = ["Character", "Classes", "Arcana & Spells", "Manage"];
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
activeTab: string;
|
||||||
|
setActiveTab: (tab: string) => void;
|
||||||
|
theme: string;
|
||||||
|
setTheme: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
saveStatus: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header({
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
saveStatus,
|
||||||
|
}: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
<div className="logo">Fabula Ultima</div>
|
||||||
|
<div className="tabs">
|
||||||
|
{TABS.map((tab, i) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={tab}
|
||||||
|
className={`tab${activeTab === tab ? " active" : ""}`}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
>
|
||||||
|
{TAB_LABELS[i]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="toolbar">
|
||||||
|
<span className={`save-status${saveStatus ? " show" : ""}`}>Saved!</span>
|
||||||
|
<button type="button" className="btn-print" onClick={() => window.print()}>
|
||||||
|
⎙ Print
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-theme"
|
||||||
|
onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}
|
||||||
|
>
|
||||||
|
{theme === "light" ? "☾ Dark" : "☀ Light"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
683
src/components/MainPage.tsx
Normal file
683
src/components/MainPage.tsx
Normal file
@@ -0,0 +1,683 @@
|
|||||||
|
import React from "react";
|
||||||
|
import weaponsFile from "../../data/weapons.yml";
|
||||||
|
import armorShieldsFile from "../../data/armor_shields.yml";
|
||||||
|
import { Fields, Bond, CheckMap } from "../types";
|
||||||
|
import { STATUSES, FEELINGS, MUTUAL_EXCLUSIVE, MARTIAL_ITEMS } from "../constants";
|
||||||
|
|
||||||
|
interface MainPageProps {
|
||||||
|
isActive: boolean;
|
||||||
|
fields: Fields;
|
||||||
|
f: <K extends keyof Fields>(key: K, val: Fields[K]) => void;
|
||||||
|
level: number;
|
||||||
|
setLevel: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
xp: number;
|
||||||
|
xpPct: number;
|
||||||
|
fp: number;
|
||||||
|
setFp: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
fpTotal: number;
|
||||||
|
inCrisis: boolean;
|
||||||
|
calcHP: () => void;
|
||||||
|
calcMP: () => void;
|
||||||
|
statuses: CheckMap;
|
||||||
|
toggleStatus: (s: string) => void;
|
||||||
|
martial: CheckMap;
|
||||||
|
toggleMartial: (m: string) => void;
|
||||||
|
bonds: Bond[];
|
||||||
|
setBonds: React.Dispatch<React.SetStateAction<Bond[]>>;
|
||||||
|
toggleFeeling: (bondIdx: number, feeling: string) => void;
|
||||||
|
weaponPickerOpen: boolean;
|
||||||
|
setWeaponPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
weaponCategory: string;
|
||||||
|
setWeaponCategory: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MainPage({
|
||||||
|
isActive,
|
||||||
|
fields,
|
||||||
|
f,
|
||||||
|
level,
|
||||||
|
setLevel,
|
||||||
|
xp,
|
||||||
|
xpPct,
|
||||||
|
fp,
|
||||||
|
setFp,
|
||||||
|
fpTotal,
|
||||||
|
inCrisis,
|
||||||
|
calcHP,
|
||||||
|
calcMP,
|
||||||
|
statuses,
|
||||||
|
toggleStatus,
|
||||||
|
martial,
|
||||||
|
toggleMartial,
|
||||||
|
bonds,
|
||||||
|
setBonds,
|
||||||
|
toggleFeeling,
|
||||||
|
weaponPickerOpen,
|
||||||
|
setWeaponPickerOpen,
|
||||||
|
weaponCategory,
|
||||||
|
setWeaponCategory,
|
||||||
|
}: MainPageProps) {
|
||||||
|
return (
|
||||||
|
<div className={`page${isActive ? " active" : ""}`} id="page-main">
|
||||||
|
{/* Row 1: Identity + Level */}
|
||||||
|
<div className="grid-2" style={{ marginBottom: 20 }}>
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">✦</span> Identity & Traits
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field" style={{ flex: 2 }}>
|
||||||
|
<label htmlFor="character-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="character-name"
|
||||||
|
type="text"
|
||||||
|
value={fields.charName}
|
||||||
|
onChange={(e) => f("charName", e.target.value)}
|
||||||
|
placeholder="Character name…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="character-pronouns">Pronouns</label>
|
||||||
|
<input
|
||||||
|
id="character-pronouns"
|
||||||
|
type="text"
|
||||||
|
value={fields.charPronouns}
|
||||||
|
onChange={(e) => f("charPronouns", e.target.value)}
|
||||||
|
placeholder="they/them"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="character-identity">Identity</label>
|
||||||
|
<input
|
||||||
|
id="character-identity"
|
||||||
|
type="text"
|
||||||
|
value={fields.charIdentity}
|
||||||
|
onChange={(e) => f("charIdentity", e.target.value)}
|
||||||
|
placeholder="Who are you?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="character-theme">Theme</label>
|
||||||
|
<input
|
||||||
|
id="character-theme"
|
||||||
|
type="text"
|
||||||
|
value={fields.charTheme}
|
||||||
|
onChange={(e) => f("charTheme", e.target.value)}
|
||||||
|
placeholder="Your theme…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="character-origin">Origin</label>
|
||||||
|
<input
|
||||||
|
id="character-origin"
|
||||||
|
type="text"
|
||||||
|
value={fields.charOrigin}
|
||||||
|
onChange={(e) => f("charOrigin", e.target.value)}
|
||||||
|
placeholder="Where from?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="character-traits">Traits (comma-separated)</label>
|
||||||
|
<textarea
|
||||||
|
id="character-traits"
|
||||||
|
value={fields.charTraits}
|
||||||
|
onChange={(e) => f("charTraits", e.target.value)}
|
||||||
|
placeholder="Brave, Reckless, Loyal to a fault…"
|
||||||
|
style={{ minHeight: 55 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">⬡</span> Level & Experience
|
||||||
|
</div>
|
||||||
|
<div className="grid-2" style={{ gap: 14 }}>
|
||||||
|
<div className="level-display">
|
||||||
|
<span className="level-num">{level}</span>
|
||||||
|
<span className="level-text">Character Level</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="add-btn"
|
||||||
|
style={{ marginTop: 10, width: "100%", justifyContent: "center" }}
|
||||||
|
onClick={() => setLevel((l) => Math.min(50, l + 1))}
|
||||||
|
>
|
||||||
|
+ Level Up
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="add-btn"
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
width: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderColor: "var(--border-bright)",
|
||||||
|
color: "var(--text-dim)",
|
||||||
|
}}
|
||||||
|
onClick={() => setLevel((l) => Math.max(1, l - 1))}
|
||||||
|
>
|
||||||
|
− Level Down
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="character-xp-current">Experience Points (XP)</label>
|
||||||
|
<input
|
||||||
|
id="character-xp-current"
|
||||||
|
type="number"
|
||||||
|
value={fields.xpCurrent}
|
||||||
|
onChange={(e) => f("xpCurrent", e.target.value)}
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="xp-bar-wrap">
|
||||||
|
<div className="xp-bar" style={{ width: xpPct + "%" }} />
|
||||||
|
</div>
|
||||||
|
<div className="xp-label">
|
||||||
|
<span>{xp} XP</span>
|
||||||
|
<span>10 XP = Level</span>
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ marginTop: 12 }}>
|
||||||
|
<label htmlFor="character-zenit">Zenit (currency)</label>
|
||||||
|
<input
|
||||||
|
id="character-zenit"
|
||||||
|
type="number"
|
||||||
|
value={fields.zenit}
|
||||||
|
onChange={(e) => f("zenit", e.target.value)}
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">⊕</span> Defenses
|
||||||
|
</div>
|
||||||
|
<div className="def-row">
|
||||||
|
<div className="def-block">
|
||||||
|
<label htmlFor="character-initiative">Initiative Mod</label>
|
||||||
|
<input
|
||||||
|
id="character-initiative"
|
||||||
|
type="number"
|
||||||
|
value={fields.initMod}
|
||||||
|
onChange={(e) => f("initMod", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="def-block">
|
||||||
|
<label htmlFor="character-defense">Defense</label>
|
||||||
|
<input
|
||||||
|
id="character-defense"
|
||||||
|
type="number"
|
||||||
|
value={fields.defense}
|
||||||
|
onChange={(e) => f("defense", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="def-block">
|
||||||
|
<label htmlFor="character-magic-defense">Magic Defense</label>
|
||||||
|
<input
|
||||||
|
id="character-magic-defense"
|
||||||
|
type="number"
|
||||||
|
value={fields.magDef}
|
||||||
|
onChange={(e) => f("magDef", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Attributes + Status + Vitals */}
|
||||||
|
<div className="grid-3" style={{ marginBottom: 20 }}>
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">◈</span> Attributes
|
||||||
|
</div>
|
||||||
|
<div className="attr-grid">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ label: "Dexterity", base: "dexBase", cur: "dexCur" },
|
||||||
|
{ label: "Insight", base: "insBase", cur: "insCur" },
|
||||||
|
{ label: "Might", base: "migBase", cur: "migCur" },
|
||||||
|
{ label: "Willpower", base: "wlpBase", cur: "wlpCur" },
|
||||||
|
] as const
|
||||||
|
).map(({ label, base, cur }) => (
|
||||||
|
<div key={label} className="attr-block">
|
||||||
|
<div className="attr-name">{label}</div>
|
||||||
|
<div className="attr-inputs">
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={{ fontSize: "0.48rem" }}>Base</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={fields[base]}
|
||||||
|
onChange={(e) => f(base, e.target.value)}
|
||||||
|
min="6"
|
||||||
|
max="12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="attr-sep">→</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={{ fontSize: "0.48rem" }}>Current</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={fields[cur]}
|
||||||
|
onChange={(e) => f(cur, e.target.value)}
|
||||||
|
min="6"
|
||||||
|
max="12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">⚠</span> Status Effects
|
||||||
|
</div>
|
||||||
|
<div className="status-grid">
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s}
|
||||||
|
className={`status-item${statuses[s] ? " active-status" : ""}`}
|
||||||
|
onClick={() => toggleStatus(s)}
|
||||||
|
>
|
||||||
|
<div className="status-check">{statuses[s] ? "✗" : ""}</div>
|
||||||
|
<div className="status-label">{s}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">♥</span> Hit, Mind & Inventory Points
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vital-block">
|
||||||
|
<div className="vital-label hp">HP</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="vital-formula">MIG×5 + Level + Other</div>
|
||||||
|
<div className="vital-inputs">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={fields.hpCur}
|
||||||
|
onChange={(e) => f("hpCur", e.target.value)}
|
||||||
|
placeholder="Cur"
|
||||||
|
/>
|
||||||
|
<div className="vital-sep">/</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={fields.hpMax}
|
||||||
|
onChange={(e) => f("hpMax", e.target.value)}
|
||||||
|
placeholder="Max"
|
||||||
|
/>
|
||||||
|
{inCrisis && <div className="crisis-badge">CRISIS</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="add-btn"
|
||||||
|
style={{ padding: "4px 8px" }}
|
||||||
|
onClick={calcHP}
|
||||||
|
title="Auto-calculate from Might"
|
||||||
|
>
|
||||||
|
Calc
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vital-block">
|
||||||
|
<div className="vital-label mp">MP</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="vital-formula">WLP×5 + Level + Other</div>
|
||||||
|
<div className="vital-inputs">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={fields.mpCur}
|
||||||
|
onChange={(e) => f("mpCur", e.target.value)}
|
||||||
|
placeholder="Cur"
|
||||||
|
/>
|
||||||
|
<div className="vital-sep">/</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={fields.mpMax}
|
||||||
|
onChange={(e) => f("mpMax", e.target.value)}
|
||||||
|
placeholder="Max"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="add-btn"
|
||||||
|
style={{ padding: "4px 8px" }}
|
||||||
|
onClick={calcMP}
|
||||||
|
title="Auto-calculate from Willpower"
|
||||||
|
>
|
||||||
|
Calc
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vital-block">
|
||||||
|
<div className="vital-label ip">IP</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="vital-formula">6 + Other</div>
|
||||||
|
<div className="vital-inputs">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={fields.ipCur}
|
||||||
|
onChange={(e) => f("ipCur", e.target.value)}
|
||||||
|
placeholder="Cur"
|
||||||
|
/>
|
||||||
|
<div className="vital-sep">/</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={fields.ipMax}
|
||||||
|
onChange={(e) => f("ipMax", e.target.value)}
|
||||||
|
placeholder="Max"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: FP + Bonds */}
|
||||||
|
<div className="grid-2" style={{ marginBottom: 20 }}>
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">✦</span> Fabula Points
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap" }}>
|
||||||
|
<div>
|
||||||
|
<label>Current FP</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={fp}
|
||||||
|
min="0"
|
||||||
|
max="20"
|
||||||
|
onChange={(e) => setFp(parseInt(e.target.value, 10) || 0)}
|
||||||
|
style={{
|
||||||
|
width: 70,
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "1.4rem",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="fp-pips">
|
||||||
|
{Array.from({ length: fpTotal }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`fp-pip${i < fp ? " filled" : ""}`}
|
||||||
|
onClick={() => setFp(i < fp ? i : i + 1)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="fp-rules">
|
||||||
|
<div className="fp-rule">
|
||||||
|
<strong>+1 FP</strong> if you have none at start of session.
|
||||||
|
</div>
|
||||||
|
<div className="fp-rule">
|
||||||
|
<strong>+1 FP</strong> when a Villain makes an entrance.
|
||||||
|
</div>
|
||||||
|
<div className="fp-rule">
|
||||||
|
<strong>+1 FP</strong> when you fumble a Check.
|
||||||
|
</div>
|
||||||
|
<div className="fp-rule">
|
||||||
|
<strong>+2 FP</strong> if you surrender at zero HP.
|
||||||
|
</div>
|
||||||
|
<div className="fp-rule" style={{ marginTop: 6 }}>
|
||||||
|
<strong>Spend 1 FP</strong> to invoke a trait: reroll one or both dice.
|
||||||
|
</div>
|
||||||
|
<div className="fp-rule">
|
||||||
|
<strong>Spend 1 FP</strong> to invoke a bond: add its strength to the result.
|
||||||
|
</div>
|
||||||
|
<div className="fp-rule">
|
||||||
|
<strong>Spend 1 FP</strong> to alter the story.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">⊗</span> Bonds
|
||||||
|
</div>
|
||||||
|
{bonds.map((bond, idx) => (
|
||||||
|
<div key={idx} className="bond-block">
|
||||||
|
<div className="bond-header">
|
||||||
|
<div className="bond-num">{idx + 1}</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Bond target name…"
|
||||||
|
value={bond.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBonds((prev) =>
|
||||||
|
prev.map((b, i) => (i === idx ? { ...b, name: e.target.value } : b))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="bond-feelings">
|
||||||
|
{FEELINGS.map((feeling) => {
|
||||||
|
const isActive = bond.feelings.includes(feeling);
|
||||||
|
const isDisabled =
|
||||||
|
!isActive && bond.feelings.includes(MUTUAL_EXCLUSIVE[feeling]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={feeling}
|
||||||
|
className={`bond-feeling${isActive ? " active" : ""}${isDisabled ? " disabled" : ""}`}
|
||||||
|
onClick={() => !isDisabled && toggleFeeling(idx, feeling)}
|
||||||
|
>
|
||||||
|
<div className="bond-feeling-box">{isActive ? "✓" : ""}</div>
|
||||||
|
<span>{feeling}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Equipment + Backpack */}
|
||||||
|
<div className="grid-2" style={{ marginBottom: 20 }}>
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">⚔</span> Equipment
|
||||||
|
</div>
|
||||||
|
<div className="martial-row">
|
||||||
|
{MARTIAL_ITEMS.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m}
|
||||||
|
className={`martial-item${martial[m] ? " checked" : ""}`}
|
||||||
|
onClick={() => toggleMartial(m)}
|
||||||
|
>
|
||||||
|
<div className="martial-box">{martial[m] ? "✓" : ""}</div>
|
||||||
|
<span>{m}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
slot: "Accessory",
|
||||||
|
name: "accName",
|
||||||
|
desc: "accDesc",
|
||||||
|
namePh: "Item name",
|
||||||
|
descPh: "Description / effect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "Armor",
|
||||||
|
name: "armName",
|
||||||
|
desc: "armDesc",
|
||||||
|
namePh: "Item name",
|
||||||
|
descPh: "Defense bonus / effect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "Main Hand",
|
||||||
|
name: "mhName",
|
||||||
|
desc: "mhDesc",
|
||||||
|
namePh: "Weapon name",
|
||||||
|
descPh: "Damage / effect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "Off-Hand",
|
||||||
|
name: "ohName",
|
||||||
|
desc: "ohDesc",
|
||||||
|
namePh: "Weapon / shield",
|
||||||
|
descPh: "Damage / effect",
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
).map(({ slot, name, desc, namePh, descPh }) => (
|
||||||
|
<div key={slot} className="equip-row">
|
||||||
|
<div className="equip-slot">{slot}</div>
|
||||||
|
<div className="equip-fields">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={fields[name]}
|
||||||
|
onChange={(e) => f(name, e.target.value)}
|
||||||
|
placeholder={namePh}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={fields[desc]}
|
||||||
|
onChange={(e) => f(desc, e.target.value)}
|
||||||
|
placeholder={descPh}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">◉</span> Backpack & Notes
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={fields.backpack}
|
||||||
|
onChange={(e) => f("backpack", e.target.value)}
|
||||||
|
placeholder="Items, notes, lore…"
|
||||||
|
style={{ minHeight: 200 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="add-btn"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onClick={() => {
|
||||||
|
setWeaponCategory("all");
|
||||||
|
setWeaponPickerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add Equipment
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{weaponPickerOpen &&
|
||||||
|
(() => {
|
||||||
|
const allWeapons = (weaponsFile as WeaponsFile).weapons;
|
||||||
|
const allArmorShields = (armorShieldsFile as ArmorShieldsFile).armor_shields;
|
||||||
|
type PickerItem =
|
||||||
|
| { kind: "weapon"; data: WeaponTemplate }
|
||||||
|
| { kind: "armor"; data: ArmorShieldTemplate };
|
||||||
|
const allItems: PickerItem[] = [
|
||||||
|
...allWeapons.map((w) => ({ kind: "weapon" as const, data: w })),
|
||||||
|
...allArmorShields.map((a) => ({ kind: "armor" as const, data: a })),
|
||||||
|
];
|
||||||
|
const categories = [
|
||||||
|
"all",
|
||||||
|
...Array.from(new Set(allItems.map((i) => i.data.category))).sort(),
|
||||||
|
];
|
||||||
|
const visible =
|
||||||
|
weaponCategory === "all"
|
||||||
|
? allItems
|
||||||
|
: allItems.filter((i) => i.data.category === weaponCategory);
|
||||||
|
const formatLine = (item: PickerItem) => {
|
||||||
|
if (item.kind === "weapon") {
|
||||||
|
const w = item.data;
|
||||||
|
return `• ${w.name}: Acc ${w.accuracy}, Dmg ${w.damage}${w.description ? ` | ${w.description}` : ""}${w.cost > 0 ? ` (${w.cost}z)` : ""}`;
|
||||||
|
} else {
|
||||||
|
const a = item.data;
|
||||||
|
const init = a.initiative ?? a.initative ?? 0;
|
||||||
|
return `• ${a.name}: DEF ${a.defense}, MDEF ${a.magic_defense}, Init ${init}${a.description ? ` | ${a.description}` : ""}${a.cost > 0 ? ` (${a.cost}z)` : ""}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="spell-picker-overlay"
|
||||||
|
onClick={() => setWeaponPickerOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="spell-picker-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="spell-picker-header">
|
||||||
|
<span>Choose equipment</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="spell-picker-close"
|
||||||
|
onClick={() => setWeaponPickerOpen(false)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 6,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
padding: "8px 14px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={cat}
|
||||||
|
className={`add-btn${weaponCategory === cat ? " active-filter" : ""}`}
|
||||||
|
style={{
|
||||||
|
padding: "2px 8px",
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
textTransform: "capitalize",
|
||||||
|
}}
|
||||||
|
onClick={() => setWeaponCategory(cat)}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ul className="spell-picker-list">
|
||||||
|
{visible.map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="spell-picker-item"
|
||||||
|
onClick={() => {
|
||||||
|
const line = formatLine(item);
|
||||||
|
f(
|
||||||
|
"backpack",
|
||||||
|
fields.backpack ? fields.backpack + "\n" + line : line,
|
||||||
|
);
|
||||||
|
setWeaponPickerOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="spell-picker-name">{item.data.name}</span>
|
||||||
|
<span className="spell-picker-class">{item.data.category}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/components/ManagePage.tsx
Normal file
96
src/components/ManagePage.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface ManagePageProps {
|
||||||
|
isActive: boolean;
|
||||||
|
saveSheet: () => void;
|
||||||
|
loadSheet: () => void;
|
||||||
|
exportSheet: () => void;
|
||||||
|
handleImportFile: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
importFileRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
copyShareURL: () => Promise<void>;
|
||||||
|
copyStatus: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ManagePage({
|
||||||
|
isActive,
|
||||||
|
saveSheet,
|
||||||
|
loadSheet,
|
||||||
|
exportSheet,
|
||||||
|
handleImportFile,
|
||||||
|
importFileRef,
|
||||||
|
copyShareURL,
|
||||||
|
copyStatus,
|
||||||
|
}: ManagePageProps) {
|
||||||
|
return (
|
||||||
|
<div className={`page${isActive ? " active" : ""}`} id="page-manage">
|
||||||
|
<div className="manage-grid">
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">◈</span> Local Save
|
||||||
|
</div>
|
||||||
|
<p className="manage-desc">
|
||||||
|
Save your character sheet to your browser's local storage, or load a previously saved
|
||||||
|
sheet.
|
||||||
|
</p>
|
||||||
|
<div className="manage-btn-row">
|
||||||
|
<button type="button" className="btn-save btn-lg" onClick={saveSheet}>
|
||||||
|
✦ Save to Browser
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-load btn-lg" onClick={loadSheet}>
|
||||||
|
↑ Load from Browser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">⊕</span> JSON File
|
||||||
|
</div>
|
||||||
|
<p className="manage-desc">
|
||||||
|
Export your character to a JSON file for backup or sharing, or import from a previously
|
||||||
|
exported file.
|
||||||
|
</p>
|
||||||
|
<div className="manage-btn-row">
|
||||||
|
<button type="button" className="btn-save btn-export btn-lg" onClick={exportSheet}>
|
||||||
|
↓ Export JSON
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-load btn-import btn-lg"
|
||||||
|
onClick={() => {
|
||||||
|
if (!importFileRef.current) return;
|
||||||
|
importFileRef.current.value = "";
|
||||||
|
importFileRef.current.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↑ Import JSON
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={importFileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleImportFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section col-span-2">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">⎘</span> Share via URL
|
||||||
|
</div>
|
||||||
|
<p className="manage-desc">
|
||||||
|
Encode your character's current state into a shareable link. Anyone who opens the link
|
||||||
|
will see your character — auto-save is disabled for viewers.
|
||||||
|
</p>
|
||||||
|
<div className="manage-btn-row">
|
||||||
|
<button type="button" className="btn-save btn-export btn-lg" onClick={copyShareURL}>
|
||||||
|
⎘ Copy URL
|
||||||
|
</button>
|
||||||
|
<span className={`save-status${copyStatus ? " show" : ""}`}>Copied!</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
236
src/components/SpellsPage.tsx
Normal file
236
src/components/SpellsPage.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import React from "react";
|
||||||
|
import spellsFile from "../../data/spells.yml";
|
||||||
|
import { Spell, CheckMap } from "../types";
|
||||||
|
import { DISCIPLINES } from "../constants";
|
||||||
|
import { autoResize } from "../utils";
|
||||||
|
|
||||||
|
interface SpellsPageProps {
|
||||||
|
isActive: boolean;
|
||||||
|
spells: Spell[];
|
||||||
|
setSpells: React.Dispatch<React.SetStateAction<Spell[]>>;
|
||||||
|
disciplines: CheckMap;
|
||||||
|
toggleDisc: (d: string) => void;
|
||||||
|
ritualsNotes: string;
|
||||||
|
onRitualsNotesChange: (v: string) => void;
|
||||||
|
spellPickerOpen: boolean;
|
||||||
|
setSpellPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SpellsPage({
|
||||||
|
isActive,
|
||||||
|
spells,
|
||||||
|
setSpells,
|
||||||
|
disciplines,
|
||||||
|
toggleDisc,
|
||||||
|
ritualsNotes,
|
||||||
|
onRitualsNotesChange,
|
||||||
|
spellPickerOpen,
|
||||||
|
setSpellPickerOpen,
|
||||||
|
}: SpellsPageProps) {
|
||||||
|
return (
|
||||||
|
<div className={`page${isActive ? " active" : ""}`} id="page-spells">
|
||||||
|
<div className="section" style={{ marginBottom: 20 }}>
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">✦</span> Arcana & Spells
|
||||||
|
</div>
|
||||||
|
<table className="spells-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="spell-name-col">Name / Notes</th>
|
||||||
|
<th className="spell-class-col">Class</th>
|
||||||
|
<th className="spell-mp-col">MP Cost</th>
|
||||||
|
<th className="spell-targets-col">Targets</th>
|
||||||
|
<th className="spell-dur-col">Duration</th>
|
||||||
|
<th className="spell-del-col"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{spells.map((s, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
<tr className="spell-inputs-row">
|
||||||
|
<td className="spell-name-col">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Spell / Arcana name…"
|
||||||
|
value={s.name || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSpells((prev) =>
|
||||||
|
prev.map((sp, j) => (j === i ? { ...sp, name: e.target.value } : sp))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="spell-class-col">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Class…"
|
||||||
|
value={s.spellClass || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSpells((prev) =>
|
||||||
|
prev.map((sp, j) =>
|
||||||
|
j === i ? { ...sp, spellClass: e.target.value } : sp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="spell-mp-col">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="MP cost…"
|
||||||
|
value={s.mp || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSpells((prev) =>
|
||||||
|
prev.map((sp, j) => (j === i ? { ...sp, mp: e.target.value } : sp))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="spell-targets-col">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Target(s)…"
|
||||||
|
value={s.targets || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSpells((prev) =>
|
||||||
|
prev.map((sp, j) =>
|
||||||
|
j === i ? { ...sp, targets: e.target.value } : sp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="spell-dur-col">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Duration…"
|
||||||
|
value={s.duration || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSpells((prev) =>
|
||||||
|
prev.map((sp, j) =>
|
||||||
|
j === i ? { ...sp, duration: e.target.value } : sp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="spell-del-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="spell-del-btn"
|
||||||
|
onClick={() => setSpells((prev) => prev.filter((_, j) => j !== i))}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="spell-notes-row">
|
||||||
|
<td colSpan={6}>
|
||||||
|
<textarea
|
||||||
|
placeholder="Notes / effect description…"
|
||||||
|
value={s.notes || ""}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) autoResize(el);
|
||||||
|
}}
|
||||||
|
onInput={(e) => autoResize(e.currentTarget)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSpells((prev) =>
|
||||||
|
prev.map((sp, j) =>
|
||||||
|
j === i ? { ...sp, notes: e.target.value } : sp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style={{ display: "flex", gap: 8, marginTop: 10 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="add-btn"
|
||||||
|
onClick={() =>
|
||||||
|
setSpells((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ name: "", spellClass: "", notes: "", mp: "", targets: "", duration: "" },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Add Spell / Arcana
|
||||||
|
</button>
|
||||||
|
<button type="button" className="add-btn" onClick={() => setSpellPickerOpen(true)}>
|
||||||
|
+ Add Template Spell
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{spellPickerOpen && (
|
||||||
|
<div className="spell-picker-overlay" onClick={() => setSpellPickerOpen(false)}>
|
||||||
|
<div className="spell-picker-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="spell-picker-header">
|
||||||
|
<span>Choose a spell</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="spell-picker-close"
|
||||||
|
onClick={() => setSpellPickerOpen(false)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="spell-picker-list">
|
||||||
|
{(spellsFile as SpellsFile).spells.map((t, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="spell-picker-item"
|
||||||
|
onClick={() => {
|
||||||
|
setSpells((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
name: t.name,
|
||||||
|
spellClass: t.class,
|
||||||
|
notes: t.description,
|
||||||
|
mp: t.cost,
|
||||||
|
targets: t.targets,
|
||||||
|
duration: t.duration,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setSpellPickerOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="spell-picker-name">{t.name}</span>
|
||||||
|
<span className="spell-picker-class">{t.class}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="icon">⊕</span> Rituals
|
||||||
|
</div>
|
||||||
|
<div className="disciplines-row">
|
||||||
|
{DISCIPLINES.map((d) => (
|
||||||
|
<div
|
||||||
|
key={d}
|
||||||
|
className={`disc-item${disciplines[d] ? " checked" : ""}`}
|
||||||
|
onClick={() => toggleDisc(d)}
|
||||||
|
>
|
||||||
|
<div className="disc-box">{disciplines[d] ? "✓" : ""}</div>
|
||||||
|
<span>{d}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={ritualsNotes}
|
||||||
|
onChange={(e) => onRitualsNotesChange(e.target.value)}
|
||||||
|
placeholder="Record ritual details, components, and notes here…"
|
||||||
|
style={{ minHeight: 120 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/constants.ts
Normal file
31
src/constants.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export const STATUSES = ["Slow", "Enraged", "Dazed", "Weak", "Poisoned", "Shaken"];
|
||||||
|
export const FEELINGS = [
|
||||||
|
"Admiration",
|
||||||
|
"Loyalty",
|
||||||
|
"Hatred",
|
||||||
|
"Inferiority",
|
||||||
|
"Mistrust",
|
||||||
|
"Affection",
|
||||||
|
];
|
||||||
|
export const MUTUAL_EXCLUSIVE: Record<string, string> = {
|
||||||
|
Admiration: "Inferiority",
|
||||||
|
Inferiority: "Admiration",
|
||||||
|
Loyalty: "Mistrust",
|
||||||
|
Mistrust: "Loyalty",
|
||||||
|
Hatred: "Affection",
|
||||||
|
Affection: "Hatred",
|
||||||
|
};
|
||||||
|
export const MARTIAL_ITEMS = [
|
||||||
|
"Martial Armor",
|
||||||
|
"Martial Shields",
|
||||||
|
"Martial Melee",
|
||||||
|
"Martial Ranged",
|
||||||
|
];
|
||||||
|
export const DISCIPLINES = [
|
||||||
|
"Arcanism",
|
||||||
|
"Chimerism",
|
||||||
|
"Elementalism",
|
||||||
|
"Entropism",
|
||||||
|
"Ritualism",
|
||||||
|
"Spiritism",
|
||||||
|
];
|
||||||
11
src/globals.d.ts
vendored
11
src/globals.d.ts
vendored
@@ -47,6 +47,17 @@ interface SpellsFile {
|
|||||||
spells: SpellTemplate[];
|
spells: SpellTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SkillTemplate {
|
||||||
|
name: string;
|
||||||
|
class: string;
|
||||||
|
max_level: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillsFile {
|
||||||
|
skills: SkillTemplate[];
|
||||||
|
}
|
||||||
|
|
||||||
interface BookPage {
|
interface BookPage {
|
||||||
n: number;
|
n: number;
|
||||||
content: string;
|
content: string;
|
||||||
|
|||||||
64
src/types.ts
Normal file
64
src/types.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
export interface Fields {
|
||||||
|
charName: string;
|
||||||
|
charPronouns: string;
|
||||||
|
charIdentity: string;
|
||||||
|
charTheme: string;
|
||||||
|
charOrigin: string;
|
||||||
|
charTraits: string;
|
||||||
|
xpCurrent: string | number;
|
||||||
|
zenit: string | number;
|
||||||
|
initMod: string | number;
|
||||||
|
defense: string | number;
|
||||||
|
magDef: string | number;
|
||||||
|
dexBase: string | number;
|
||||||
|
dexCur: string | number;
|
||||||
|
insBase: string | number;
|
||||||
|
insCur: string | number;
|
||||||
|
migBase: string | number;
|
||||||
|
migCur: string | number;
|
||||||
|
wlpBase: string | number;
|
||||||
|
wlpCur: string | number;
|
||||||
|
hpMax: string | number;
|
||||||
|
hpCur: string | number;
|
||||||
|
mpMax: string | number;
|
||||||
|
mpCur: string | number;
|
||||||
|
ipMax: string | number;
|
||||||
|
ipCur: string | number;
|
||||||
|
backpack: string;
|
||||||
|
heroicSkills: string;
|
||||||
|
ritualsNotes: string;
|
||||||
|
accName: string;
|
||||||
|
accDesc: string;
|
||||||
|
armName: string;
|
||||||
|
armDesc: string;
|
||||||
|
mhName: string;
|
||||||
|
mhDesc: string;
|
||||||
|
ohName: string;
|
||||||
|
ohDesc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Bond {
|
||||||
|
name: string;
|
||||||
|
feelings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClassEntry {
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
benefits: string;
|
||||||
|
skills: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Spell {
|
||||||
|
name: string;
|
||||||
|
spellClass: string;
|
||||||
|
notes: string;
|
||||||
|
mp: string;
|
||||||
|
targets: string;
|
||||||
|
duration: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CheckMap = Record<string, boolean>;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type SavedData = Record<string, any>;
|
||||||
4
src/utils.ts
Normal file
4
src/utils.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export function autoResize(el: HTMLTextAreaElement) {
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = el.scrollHeight + "px";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user