Compare commits
10 Commits
6461039bd7
...
polish/arc
| Author | SHA1 | Date | |
|---|---|---|---|
| 3effb0406e | |||
| b6d8e34090 | |||
| e26f135b79 | |||
| de37809eec | |||
| efea999a2b | |||
| aed051afaa | |||
| 410eb3a5a8 | |||
| 6609956711 | |||
| 122a4cc881 | |||
| 21c8bf5be1 |
@@ -5,10 +5,23 @@
|
||||
"resolved": "ghcr.io/anthropics/devcontainer-features/claude-code@sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a",
|
||||
"integrity": "sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:2.0.0": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "ghcr.io/devcontainers/features/node@sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f",
|
||||
"integrity": "sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f"
|
||||
"ghcr.io/devcontainers-community/npm-features/typescript:1": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "ghcr.io/devcontainers-community/npm-features/typescript@sha256:13a0f63e88513a6022431c39b7ca4ec732ba0760cdb6d882638f4ddf73deb0e7",
|
||||
"integrity": "sha256:13a0f63e88513a6022431c39b7ca4ec732ba0760cdb6d882638f4ddf73deb0e7",
|
||||
"dependsOn": [
|
||||
"ghcr.io/devcontainers/features/node"
|
||||
]
|
||||
},
|
||||
"ghcr.io/devcontainers/features/go:1": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032",
|
||||
"integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:2.1.0": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "ghcr.io/devcontainers/features/node@sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857",
|
||||
"integrity": "sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857"
|
||||
},
|
||||
"ghcr.io/jsburckhardt/devcontainer-features/just:1": {
|
||||
"version": "1.0.0",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:2.0.0": {},
|
||||
"ghcr.io/devcontainers/features/node:2.1.0": {},
|
||||
"ghcr.io/devcontainers/features/go:1": {},
|
||||
"ghcr.io/jsburckhardt/devcontainer-features/just:1": {},
|
||||
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}
|
||||
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
|
||||
"ghcr.io/devcontainers/features/node:2": {},
|
||||
"ghcr.io/devcontainers-community/npm-features/typescript:1": {}
|
||||
}
|
||||
}
|
||||
79
.gitea/workflows/deploy.yaml
Normal file
79
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,79 @@
|
||||
name: Deploy
|
||||
|
||||
# Builds the static frontend and the Go share-service, ships both to the
|
||||
# Caddy host, and restarts the service.
|
||||
#
|
||||
# Required repo secret:
|
||||
# DEPLOY_SSH_KEY - base64-encoded private SSH key authorized for
|
||||
# ${DEPLOY_USER}@${DEPLOY_HOST}. Generate with:
|
||||
# base64 -w0 < deploy_key # Linux
|
||||
# base64 -i deploy_key | tr -d '\n' # macOS
|
||||
#
|
||||
# Override host/user/paths via repo variables (Settings > Actions > Variables)
|
||||
# if they ever change; the defaults below match the Justfile.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DEPLOY_HOST: ${{ vars.DEPLOY_HOST || 'goldfish.malzahn.lan' }}
|
||||
DEPLOY_USER: ${{ vars.DEPLOY_USER || 'root' }}
|
||||
WWW_ROOT: ${{ vars.WWW_ROOT || '/usr/share/caddy/public_html/fabula' }}
|
||||
GO_ARCH: ${{ vars.GO_ARCH || 'amd64' }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# --- Build frontend ---
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build # -> dist/
|
||||
|
||||
# --- Build backend (static, no cgo) ---
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
- name: Build share-svc
|
||||
working-directory: server
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH="$GO_ARCH" go build -o share-svc .
|
||||
|
||||
# --- Deploy ---
|
||||
- name: Configure SSH
|
||||
run: |
|
||||
command -v rsync >/dev/null || { apt-get update && apt-get install -y rsync; }
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
# DEPLOY_SSH_KEY is stored base64-encoded so the PEM survives the
|
||||
# secret round-trip intact (no CRLF/whitespace/newline mangling).
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" | base64 -d > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Copy frontend
|
||||
run: |
|
||||
rsync -az --delete -e "ssh -i ~/.ssh/deploy_key" \
|
||||
dist/ "$DEPLOY_USER@$DEPLOY_HOST:$WWW_ROOT/"
|
||||
|
||||
- name: Copy backend binary + unit file
|
||||
run: |
|
||||
scp -i ~/.ssh/deploy_key server/share-svc \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:/usr/local/bin/share-svc"
|
||||
scp -i ~/.ssh/deploy_key server/share-svc.service \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:/etc/systemd/system/share-svc.service"
|
||||
|
||||
- name: Restart services
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" '
|
||||
systemctl daemon-reload &&
|
||||
systemctl enable --now share-svc &&
|
||||
systemctl restart share-svc &&
|
||||
systemctl reload caddy
|
||||
'
|
||||
14
Justfile
14
Justfile
@@ -6,8 +6,12 @@ user := "root"
|
||||
host := "goldfish.malzahn.lan"
|
||||
www-root := "/usr/share/caddy/public_html/fabula"
|
||||
|
||||
# Target architecture of the deploy host (override: `just go-arch=arm64 deploy-server`)
|
||||
go-arch := "amd64"
|
||||
|
||||
clean:
|
||||
rm -rf dist/*
|
||||
rm -f server/share-svc
|
||||
|
||||
serve:
|
||||
npm run dev
|
||||
@@ -18,6 +22,16 @@ build:
|
||||
deploy: build
|
||||
scp -r dist/* {{ user }}@{{ host }}:{{ www-root }}/
|
||||
|
||||
# Build the share service as a static binary for the deploy host.
|
||||
build-server:
|
||||
cd server && CGO_ENABLED=0 GOOS=linux GOARCH={{ go-arch }} go build -o share-svc .
|
||||
|
||||
# Ship the binary + unit file and (re)start the service. Requires root on host.
|
||||
deploy-server: build-server
|
||||
scp server/share-svc {{ user }}@{{ host }}:/usr/local/bin/share-svc
|
||||
scp server/share-svc.service {{ user }}@{{ host }}:/etc/systemd/system/share-svc.service
|
||||
ssh {{ user }}@{{ host }} 'systemctl daemon-reload && systemctl enable --now share-svc && systemctl restart share-svc'
|
||||
|
||||
format:
|
||||
npx prettier --write books/
|
||||
npx prettier --write src/
|
||||
|
||||
0
data/spells.json
Normal file
0
data/spells.json
Normal file
50
data/spells.schema.json
Normal file
50
data/spells.schema.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "data/spells.schema.json",
|
||||
"title": "Spell Sheet",
|
||||
"description": "A collection of Fabula Ultima spells and arcana entries.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"spells": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the spell or arcana."
|
||||
},
|
||||
"cost": {
|
||||
"type": "string",
|
||||
"minimum": 0,
|
||||
"description": "MP cost to cast the spell."
|
||||
},
|
||||
"targets": {
|
||||
"type": "string",
|
||||
"description": "Who or what the spell affects (e.g. \"One creature\", \"All enemies\")."
|
||||
},
|
||||
"duration": {
|
||||
"type": "string",
|
||||
"description": "How long the spell's effect lasts (e.g. \"Instantaneous\", \"Scene\")."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Full effect description and any special rules for the spell."
|
||||
},
|
||||
"class": {
|
||||
"type": "string",
|
||||
"enum": ["arcanist", "chimerist", "elementalist", "entropist", "spiritist"],
|
||||
"description": "The magic-using class this spell belongs to."
|
||||
},
|
||||
"offensive": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the spell is offensive in nature."
|
||||
}
|
||||
},
|
||||
"required": ["name", "cost", "targets", "duration", "description", "class"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["spells"]
|
||||
}
|
||||
287
data/spells.yml
Normal file
287
data/spells.yml
Normal file
@@ -0,0 +1,287 @@
|
||||
spells:
|
||||
- name: Elemental Shroud
|
||||
cost: 5 x T
|
||||
targets: Up to three creatures
|
||||
duration: scene
|
||||
description: "You weave magical energy and protect the targets from the fury of the elements. Choose a damage type: air, bolt, earth, fire or ice. Until this spell ends, each target gains Resistance against the chosen damage type."
|
||||
class: elementalist
|
||||
|
||||
- name: Elemental Weapon
|
||||
cost: "10"
|
||||
targets: One weapon
|
||||
duration: Scene
|
||||
description: "You imbue a weapon with elemental energy. Choose a damage type: air, bolt, earth, fire, or ice. Until this spell ends, all damage dealt by the weapon becomes of the chosen damage type. If you have that weapon equipped while you cast this spell, you may perform a free attack with it as part of the same action. This spell can only be cast on a weapon equipped by a willing creature."
|
||||
class: elementalist
|
||||
|
||||
- name: Flare
|
||||
cost: "20"
|
||||
targets: One creature
|
||||
duration: Instantaneous
|
||||
description: "You channel a single ray of fire towards your foe, its temperature so high that it will pierce through most defenses. The target suffers【 HR + 25】 fire damage. Damage dealt by this spell ignores Resistances."
|
||||
class: elementalist
|
||||
offensive: true
|
||||
|
||||
- name: Fulgur
|
||||
cost: "10 x T"
|
||||
targets: Up to three creatures
|
||||
duration: Instantaneous
|
||||
description: "You weave electricity into a wave of crackling bolts. Each target hit by this spell suffers 【HR + 15】 bolt damage. Opportunity: Each target hit by this spell suffers dazed."
|
||||
class: elementalist
|
||||
offensive: true
|
||||
|
||||
- name: Glacies
|
||||
cost: 10 x T
|
||||
targets: Up to three creatures
|
||||
duration: Instantaneous
|
||||
description: "You coat your foes under a thick layer of frost. Each target hit by this spell suffers【 HR + 15】 ice damage. Opportunity: Each target hit by this spell suffers slow."
|
||||
class: elementalist
|
||||
offensive: true
|
||||
|
||||
- name: Iceberg
|
||||
cost: "20"
|
||||
targets: One creature
|
||||
duration: Instantaneous
|
||||
description: "A pillar of ice magic envelops your foe, suddenly dropping their body temperature to a critical level. The target suffers【 HR + 25】 ice damage. Damage dealt by this spell ignores Resistances."
|
||||
class: elementalist
|
||||
offensive: true
|
||||
|
||||
- name: Ignis
|
||||
cost: 10 x T
|
||||
targets: Up to three creatures
|
||||
duration: Instantaneous
|
||||
description: "You unleash a searing barrage against your foes, conjuring flames out of thin air. Each target hit by this spell suffers【 HR + 15】 fire damage. Opportunity: Each target hit by this spell suffers shaken."
|
||||
class: elementalist
|
||||
offensive: true
|
||||
|
||||
- name: Soaring Strike
|
||||
cost: "10"
|
||||
targets: Self
|
||||
duration: Instantaneous
|
||||
description: "The wind carries your strikes across the battlefield. You may immediately perform a free attack with a melee weapon you have equipped. This attack may target creatures that can only be targeted by ranged attacks. If you used a weapon belonging to the brawling or spear Category for this attack, it deals 5 extra damage. If you hit a flying target with this attack, you may force them to land immediately."
|
||||
class: elementalist
|
||||
|
||||
- name: Terra
|
||||
cost: 10 x T
|
||||
targets: Up to three creatures
|
||||
duration: Instantaneous
|
||||
description: "Spires of jagged rock erupt from the ground beneath your foes, closing around them. Each target hit by this spell suffers【 HR + 15】 earth damage. This spell cannot target creatures who are flying, floating, falling, or otherwise in mid-air. Opportunity: Each target hit by this spell performs one fewer action on their next turn (to a minimum of 0 actions)."
|
||||
class: elementalist
|
||||
offensive: true
|
||||
|
||||
- name: Thunderbolt
|
||||
cost: "20"
|
||||
targets: One creature
|
||||
duration: Instantaneous
|
||||
description: "You send lightning striking at your foe. The target suffers【 HR + 25】 bolt damage. Damage dealt by this spell ignores Resistances."
|
||||
class: elementalist
|
||||
offensive: true
|
||||
|
||||
- name: Ventus
|
||||
cost: 10 x T
|
||||
targets: One creature
|
||||
duration: Instantaneosu
|
||||
description: "You summon the power of winds against your enemy. Each target hit by this spell suffers【 HR + 15】 air damage. Opportunity: Each flying target hit by this spell is forced to land immediately."
|
||||
class: elementalist
|
||||
offensive: true
|
||||
|
||||
- name: Acceleration
|
||||
cost: "20"
|
||||
targets: One creature
|
||||
duration: Scene
|
||||
description: "You bend the fabric of time. Until this spell ends, the target gains the ability to perform a single additional action during each of their turns. Once the target has performed a total of two additional actions granted by this spell, this spell ends."
|
||||
class: entropist
|
||||
offensive: false
|
||||
|
||||
- name: Anomaly
|
||||
cost: "20"
|
||||
targets: One creature
|
||||
duration: Scene
|
||||
description: "You alter the very nature of your target. Until this spell ends, if the target would suffer damage of a type they Absorb or are Immune to, they are instead treated as if they were Vulnerable to that damage type. Once that happens, this spell ends."
|
||||
class: entropist
|
||||
offensive: true
|
||||
|
||||
- name: Dark Weapon
|
||||
cost: "10"
|
||||
targets: One equipped weapon
|
||||
duration: Scene
|
||||
description: "You imbue a weapon with dark energy. Until this spell ends, all damage dealt by the weapon becomes of the dark type. If you have that weapon equipped while you cast this spell, you may perform a free attack with it as part of the same action. This spell can only be cast on a weapon equipped by a willing creature."
|
||||
class: entropist
|
||||
offensive: false
|
||||
|
||||
- name: Dispel
|
||||
cost: "10"
|
||||
targets: One creature
|
||||
duration: Instantaneous
|
||||
description: "You release a wave of negative energy and cleanse all magic from a creature. If the target is affected by one or more spells with a duration of Scene, they are no longer affected by any of those spells instead."
|
||||
class: entropist
|
||||
offensive: false
|
||||
|
||||
- name: Divination
|
||||
cost: "10"
|
||||
targets: Self
|
||||
duration: Scene
|
||||
description: "You glimpse briefly into the future. Until this spell ends, after a creature you can see performs a Check, if it was not a fumble nor a critical success, you may force that creature to reroll both dice. Once you have forced two rerolls this way, this spell ends."
|
||||
class: entropist
|
||||
offensive: false
|
||||
|
||||
- name: Drain Spirit
|
||||
cost: "5"
|
||||
targets: One creature
|
||||
duration: Instantaneous
|
||||
description: "You consume a creature's psyche. The target loses【 HR + 15】 Mind Points. Then, you recover an amount of Mind Points equal to half the Mind Points loss they suffered (if the loss was reduced to 0 in some way, you recover none)."
|
||||
class: entropist
|
||||
offensive: true
|
||||
|
||||
- name: Drain Vigor
|
||||
cost: "10"
|
||||
targets: One creature
|
||||
duration: Instantaneous
|
||||
description: "You steal another creature's life force. The target suffers【 HR + 15】 dark damage. Then, you recover an amount of Hit Points equal to half the Hit Points loss they suffered (if the loss was reduced to 0 in some way, you recover none)."
|
||||
class: entropist
|
||||
offensive: true
|
||||
|
||||
- name: Gamble
|
||||
cost: Up to 20
|
||||
targets: Special
|
||||
duration: Scene
|
||||
description: |
|
||||
You summon a vortex of chaotic energy. Roll your current Willpower die once for every 10 Mind Points spent while casting this spell, then keep the single die you prefer: the number on that die determines the effects of this spell.
|
||||
1) You lose half of your current Hit Points and half of your current Mind Points.
|
||||
2-3) Each creature present on the scene, including yourself, suffers poisoned.
|
||||
4-6) Each creature present on the scene, including yourself, suffers slow.
|
||||
7-8) Choose up to three creatures you can see: each of them recovers 50 Hit Points and also recovers from all status effects.
|
||||
9+) Choose any number of creatures you can see: each of them suffers 30 damage. The damage type is determined randomly by rolling a d6: 1. air 2. bolt 3. dark 4. earth 5. fire 6. poison
|
||||
class: entropist
|
||||
offensive: false
|
||||
|
||||
- name: Mirror
|
||||
cost: "10"
|
||||
targets: One creature
|
||||
duration: Scene
|
||||
description: "You twist the laws of magic. Until this spell ends, if an offensive (r) spell is cast on the target, the creature who cast that offensive spell will be targeted in their stead (any other targets of the offensive spell will be targeted as normal). Once that happens, this spell ends."
|
||||
class: entropist
|
||||
offensive: false
|
||||
|
||||
- name: Omega
|
||||
cost: "20"
|
||||
targets: One creature
|
||||
duration: Instantaneous
|
||||
description: "You invoke doom on your foe, turning strength into frailty. The target loses an amount of Hit Points equal to【 20 + half the target's level】"
|
||||
class: entropist
|
||||
offensive: true
|
||||
|
||||
- name: Stop
|
||||
cost: "10"
|
||||
targets: One creature
|
||||
duration: Instantaneous
|
||||
description: "You trap a foe inside a circle of altered time and space. The target will perform one fewer action on their next turn (to a minimum of 0 actions)."
|
||||
class: entropist
|
||||
offensive: true
|
||||
|
||||
- name: Umbra
|
||||
cost: 10 x T
|
||||
targets: Up to three creatures
|
||||
duration: Scene
|
||||
description: "A storm of dark energy turns matter into ash. Each target hit by this spell suffers 【HR + 15】 dark damage. Opportunity: Each target hit by this spell suffers weak."
|
||||
class: entropist
|
||||
offensive: true
|
||||
|
||||
- name: Vortex
|
||||
cost: "10"
|
||||
targets: Self
|
||||
duration: Scene
|
||||
description: "A roaring gale surrounds you, blowing away arrows and bullets. Until this spell ends, you gain a +2 bonus to your Defense against ranged attacks."
|
||||
class: elementalist
|
||||
|
||||
- name: Aura
|
||||
cost: 5 x T
|
||||
targets: Up to three creatures
|
||||
duration: scene
|
||||
description: You project your soul outside your body and direct it to surround the targets, shielding them from dangerous magic. Until this spell ends, each target may treat their Magic Defense as being equal to 12 against any effects that target it (they are still free to use their normal Defense score if higher than 12).
|
||||
class: spiritist
|
||||
|
||||
- name: Awaken
|
||||
cost: "20"
|
||||
targets: One creature
|
||||
duration: scene
|
||||
description: "You allow a creature to focus their vital energy into accomplishing what they previously could not. Choose one Attribute: Dexterity, Insight, Might, or Willpower. Until this spell ends, the target treats the chosen Attribute as if it were one die size higher (up to a maximum of d12)."
|
||||
class: spiritist
|
||||
|
||||
- name: Barrier
|
||||
cost: 5 x T
|
||||
targets: Up to three creatures
|
||||
duration: scene
|
||||
description: You project your soul outside your body and weave it into a barrier to protect the targets from attacks. Until this spell ends, each target may treat their Defense as being equal to 12 against any effects that target it (they are still free to use their normal Defense score if higher than 12).
|
||||
class: spiritist
|
||||
|
||||
- name: Cleanse
|
||||
cost: 5 x T
|
||||
targets: Up to three creatures
|
||||
duration: Instantaneous
|
||||
description: You strengthen and purify the soul energy coursing through your companions. Each target recovers from all status effects.
|
||||
class: spiritist
|
||||
|
||||
- name: Enrage
|
||||
cost: rr 10
|
||||
targets: One creature
|
||||
duration: Instantaneous
|
||||
description: You cause a creature to lose any semblance of temper and act brazenly. The target suffers enraged and cannot perform the Guard or Spell actions during their next turn.
|
||||
class: spiritist
|
||||
offensive: true
|
||||
|
||||
- name: Hallucination
|
||||
cost: rr 5 x T
|
||||
targets: Up to three creatures
|
||||
duration: Instantaneous
|
||||
description: "You alter the senses of your enemies, causing them to experience bizarre or frightening hallucinations. Choose dazed or shaken: you inflict the chosen status effect on each target hit by this spell."
|
||||
class: spiritist
|
||||
offensive: true
|
||||
|
||||
- name: Heal
|
||||
cost: 10 x T
|
||||
targets: Up to three creatures
|
||||
duration: Instantaneous
|
||||
description: "You invigorate your companions, soothing their pain and healing their fatigue. Each target recovers 40 Hit Points. This amount increases to 50 Hit Points if you are level 20 or higher, or to 60 Hit Points if you are level 40 or higher."
|
||||
class: spiritist
|
||||
offensive: false
|
||||
|
||||
- name: Lux
|
||||
cost: 10 x T
|
||||
targets: Up to three creatures
|
||||
duration: Instantaneous
|
||||
description: "You focus your inner energy into a barrage of blinding soul rays. Each target hit by this spell suffers【 HR + 15】 light damage. Opportunity: Each target hit by this spell suffers dazed."
|
||||
class: spiritist
|
||||
offensive: true
|
||||
|
||||
- name: Mercy
|
||||
cost: "20"
|
||||
targets: One creature
|
||||
duration: Scene
|
||||
description: "You strengthen the heart of a creature against suffering and despair. Until this spell ends, if the target would be reduced to 0 Hit Points, they are instead left standing with exactly 1 Hit Point. Once that happens, this spell ends."
|
||||
class: spiritist
|
||||
offensive: false
|
||||
|
||||
- name: Reinforce
|
||||
cost: 5 x T
|
||||
targets: Up to three creatures
|
||||
duration: Scene
|
||||
description: "You protect the targets from attacks that would corrupt their body and spirit. Choose dazed, enraged, poisoned, shaken, slow, or weak. Until this spell ends, each target becomes immune to the chosen status effect."
|
||||
class: spiritist
|
||||
offensive: false
|
||||
|
||||
- name: Soul Weapon
|
||||
cost: "10"
|
||||
targets: One equipped weapon
|
||||
duration: Scene
|
||||
description: "You imbue a weapon with the cleansing energy of your spirit. Until this spell ends, all damage dealt by the weapon becomes of the light type. If you have that weapon equipped while you cast this spell, you may perform a free attack with it as part of the same action. This spell can only be cast on a weapon equipped by a willing creature."
|
||||
class: spiritist
|
||||
offensive: false
|
||||
|
||||
- name: Torpor
|
||||
cost: 5 x T
|
||||
targets: Up to three creatures
|
||||
duration: Instantaneous
|
||||
description: "You smother the soul energy coursing through the bodies of your foes, hindering their movements. Choose slow or weak: you inflict the chosen status effect on each target hit by this spell."
|
||||
class: spiritist
|
||||
offensive: true
|
||||
|
||||
40
package-lock.json
generated
40
package-lock.json
generated
@@ -24,7 +24,8 @@
|
||||
"typescript": "^6.0.3",
|
||||
"webpack": "^5.107.2",
|
||||
"webpack-cli": "^7.0.3",
|
||||
"webpack-dev-server": "^5.2.4"
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
"yaml-loader": "^0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -4243,6 +4244,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/javascript-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz",
|
||||
"integrity": "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jest-regex-util": {
|
||||
"version": "30.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz",
|
||||
@@ -7233,6 +7241,36 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml-loader": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml-loader/-/yaml-loader-0.9.0.tgz",
|
||||
"integrity": "sha512-N0bPfDR42qCruELfmpF6Q8XnDu1elTULGjvVn7u6rsfDa8WhlmJbjXQRh+x5TGmpOqw8R9qfiUgc3aKP+8Hd4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"javascript-stringify": "^2.0.1",
|
||||
"yaml": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"typescript": "^6.0.3",
|
||||
"webpack": "^5.107.2",
|
||||
"webpack-cli": "^7.0.3",
|
||||
"webpack-dev-server": "^5.2.4"
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
"yaml-loader": "^0.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
69
scripts/anchors.py
Normal file
69
scripts/anchors.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = [
|
||||
# "bs4",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Dictionary to track how many times we've seen each header ID
|
||||
header_seen = {}
|
||||
|
||||
def add_anchors_to_headers(html_content):
|
||||
# Parse the HTML content
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
|
||||
# Find all header tags (h1 through h6)
|
||||
header_tags = soup.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
|
||||
|
||||
# Add a count-based ID to each header tag
|
||||
for header in header_tags:
|
||||
# Extract header text (fallback to tag name if empty)
|
||||
header_text = header.get_text(strip=True) or header.name
|
||||
|
||||
# Normalize header
|
||||
header_text = header_text.lower().replace(" ", "-")
|
||||
|
||||
# Create base ID: hN-<text>
|
||||
base_id = f"{header.name}-{header_text}"
|
||||
|
||||
# Check if we've seen this base ID before
|
||||
if base_id in header_seen:
|
||||
# Append next count: -1, -2, -3...
|
||||
count = header_seen[base_id]
|
||||
header_seen[base_id] = count + 1
|
||||
header_id = f"{base_id}-{count + 1}"
|
||||
else:
|
||||
# First time seeing this text → count starts at 1
|
||||
header_seen[base_id] = 1
|
||||
header_id = base_id
|
||||
|
||||
# Add the ID to the header tag
|
||||
header['id'] = header_id
|
||||
|
||||
# Wrap the header in an anchor link (clickable permalink)
|
||||
# The link points to itself via the id attribute
|
||||
# anchor_tag = soup.new_tag("a", href=f"#{header_id}", class_="anchor-link")
|
||||
# anchor_tag.string = f"🔗"
|
||||
# header.wrap(anchor_tag)
|
||||
|
||||
# Return the modified HTML content
|
||||
return str(soup)
|
||||
|
||||
for book in ('./books/core', './books/natural-fantasy-atlas'):
|
||||
for root, dirs, files in Path(book).walk():
|
||||
for fn in files:
|
||||
path = root / fn
|
||||
if path.suffix != ".html":
|
||||
continue
|
||||
with path.open('r') as fh:
|
||||
raw_html = fh.read()
|
||||
|
||||
new_html = add_anchors_to_headers(raw_html)
|
||||
|
||||
with path.open('w') as fh:
|
||||
fh.write(new_html)
|
||||
7
server/.gitignore
vendored
Normal file
7
server/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Compiled binary
|
||||
/share-svc
|
||||
|
||||
# SQLite database + WAL/SHM sidecars
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
21
server/Caddyfile.snippet
Normal file
21
server/Caddyfile.snippet
Normal file
@@ -0,0 +1,21 @@
|
||||
# Caddy config for the share-link service.
|
||||
#
|
||||
# The frontend (served under /fabula/) calls /fabula/api/s, but the Go service
|
||||
# routes plain /api/s. `uri strip_prefix /fabula` rewrites /fabula/api/s ->
|
||||
# /api/s before proxying to the service on loopback.
|
||||
#
|
||||
# Place this INSIDE your existing site block, and make sure this /fabula/api/*
|
||||
# handler comes BEFORE the static handler that serves /fabula/* — `handle`
|
||||
# blocks match in source order, first match wins.
|
||||
|
||||
handle /fabula/api/* {
|
||||
uri strip_prefix /fabula
|
||||
reverse_proxy 127.0.0.1:8090
|
||||
}
|
||||
|
||||
# ... your existing static file handler for /fabula/* follows here, e.g.:
|
||||
#
|
||||
# handle /fabula/* {
|
||||
# root * /usr/share/caddy/public_html
|
||||
# file_server
|
||||
# }
|
||||
51
server/README.md
Normal file
51
server/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# share-svc
|
||||
|
||||
Tiny Go + SQLite service that stores character-sheet JSON blobs and returns
|
||||
short, content-addressed IDs, so sheets share as `...?s=<id>` instead of a giant
|
||||
inline `?c=` payload.
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | Body | Response |
|
||||
| ------ | ------------ | ----------- | ------------------- |
|
||||
| POST | `/api/s` | sheet JSON | `{"id":"<id>"}` |
|
||||
| GET | `/api/s/{id}`| – | sheet JSON |
|
||||
|
||||
IDs are `base64url(sha256(json))[:12]`, so identical sheets dedupe and re-sharing
|
||||
is idempotent. Payloads are stored deflated in a single SQLite file.
|
||||
|
||||
### Config (env)
|
||||
|
||||
| Var | Default | Notes |
|
||||
| ------------- | ------------- | ------------------------------------------------ |
|
||||
| `ADDR` | `:8090` | Listen address. The unit binds `127.0.0.1:8090`. |
|
||||
| `DB_PATH` | `shares.db` | SQLite file path. |
|
||||
| `CORS_ORIGIN` | _(unset)_ | Set to allow a cross-origin dev frontend. |
|
||||
|
||||
## Deploy
|
||||
|
||||
From the repo root:
|
||||
|
||||
```sh
|
||||
just deploy-server # build (linux/amd64) + ship + restart
|
||||
just go-arch=arm64 deploy-server # if the host is arm64
|
||||
```
|
||||
|
||||
That installs `/usr/local/bin/share-svc` and `share-svc.service`, then
|
||||
`daemon-reload` + `enable --now` + `restart`. The SQLite DB lives in
|
||||
`/var/lib/share-svc/` (created by the unit's `StateDirectory`).
|
||||
|
||||
### One-time Caddy setup
|
||||
|
||||
Add the `/fabula/api/*` reverse-proxy block from `Caddyfile.snippet` to your site
|
||||
block (before the static `/fabula/*` handler) and reload Caddy.
|
||||
|
||||
## Local dev
|
||||
|
||||
`webpack serve` (port 8080) does not serve `/api/*`, so `?s=` sharing falls back
|
||||
to inline `?c=` links. To exercise short links locally, run the service and point
|
||||
the frontend at it:
|
||||
|
||||
```sh
|
||||
CORS_ORIGIN=http://localhost:8080 go run ./server
|
||||
```
|
||||
16
server/go.mod
Normal file
16
server/go.mod
Normal file
@@ -0,0 +1,16 @@
|
||||
module github.com/drew/fabula-ultima-html/server
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.52.0 // indirect
|
||||
)
|
||||
21
server/go.sum
Normal file
21
server/go.sum
Normal file
@@ -0,0 +1,21 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
|
||||
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
199
server/main.go
Normal file
199
server/main.go
Normal file
@@ -0,0 +1,199 @@
|
||||
// Command share-svc is a tiny HTTP service that stores character-sheet JSON
|
||||
// blobs and hands back short, content-addressed IDs so they can be shared via a
|
||||
// compact URL (e.g. ...?s=a1b2c3d4) instead of stuffing the whole sheet into the
|
||||
// query string.
|
||||
//
|
||||
// API:
|
||||
//
|
||||
// POST /api/s body: sheet JSON -> 200 {"id":"<id>"}
|
||||
// GET /api/s/{id} -> 200 sheet JSON
|
||||
//
|
||||
// Storage is a single SQLite file; payloads are deflated on the way in and
|
||||
// inflated on the way out. IDs are derived from the SHA-256 of the JSON, so
|
||||
// posting the same sheet twice yields the same ID (free dedupe + idempotency).
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
// idLen is how many base64url chars of the content hash form an ID.
|
||||
// 12 chars ~= 72 bits, far more than enough to avoid collisions here.
|
||||
idLen = 12
|
||||
// maxBody caps an accepted sheet payload (pre-compression).
|
||||
maxBody = 512 << 10 // 512 KiB
|
||||
)
|
||||
|
||||
type store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func openStore(path string) (*store, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Single writer; WAL keeps reads fast and concurrent.
|
||||
if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;`); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
data BLOB NOT NULL,
|
||||
created INTEGER NOT NULL
|
||||
)`); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &store{db: db}, nil
|
||||
}
|
||||
|
||||
// put stores raw (uncompressed) JSON bytes and returns its short ID. Storing the
|
||||
// same bytes again is a no-op that returns the same ID.
|
||||
func (s *store) put(raw []byte) (string, error) {
|
||||
sum := sha256.Sum256(raw)
|
||||
id := base64.RawURLEncoding.EncodeToString(sum[:])[:idLen]
|
||||
|
||||
deflated, err := deflate(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// INSERT OR IGNORE: a matching ID already holds identical content.
|
||||
if _, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO shares (id, data, created) VALUES (?, ?, ?)`,
|
||||
id, deflated, time.Now().Unix(),
|
||||
); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// get returns the stored JSON bytes for an ID, or sql.ErrNoRows if unknown.
|
||||
func (s *store) get(id string) ([]byte, error) {
|
||||
var deflated []byte
|
||||
err := s.db.QueryRow(`SELECT data FROM shares WHERE id = ?`, id).Scan(&deflated)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inflate(deflated)
|
||||
}
|
||||
|
||||
func deflate(b []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w, err := flate.NewWriter(&buf, flate.BestCompression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := w.Write(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func inflate(b []byte) ([]byte, error) {
|
||||
r := flate.NewReader(bytes.NewReader(b))
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := envOr("ADDR", ":8090")
|
||||
dbPath := envOr("DB_PATH", "shares.db")
|
||||
corsOrigin := os.Getenv("CORS_ORIGIN") // empty = same-origin only
|
||||
|
||||
st, err := openStore(dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open store: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/s", func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
|
||||
if err != nil {
|
||||
http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
if !json.Valid(raw) {
|
||||
http.Error(w, "body must be JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id, err := st.put(raw)
|
||||
if err != nil {
|
||||
log.Printf("put: %v", err)
|
||||
http.Error(w, "storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"id": id})
|
||||
})
|
||||
mux.HandleFunc("GET /api/s/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, err := st.get(r.PathValue("id"))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("get: %v", err)
|
||||
http.Error(w, "storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Shares are immutable, so let clients/proxies cache aggressively.
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(raw)
|
||||
})
|
||||
|
||||
handler := withCORS(corsOrigin, mux)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
log.Printf("share-svc listening on %s (db=%s)", addr, dbPath)
|
||||
log.Fatal(srv.ListenAndServe())
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// withCORS optionally allows a cross-origin frontend (handy when webpack-dev
|
||||
// runs on a different port than the API). When origin is empty it's a no-op.
|
||||
func withCORS(origin string, next http.Handler) http.Handler {
|
||||
if origin == "" {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
35
server/share-svc.service
Normal file
35
server/share-svc.service
Normal file
@@ -0,0 +1,35 @@
|
||||
[Unit]
|
||||
Description=Fabula Ultima share-link service
|
||||
Documentation=https://git.illaoi.pro/drew/fabula-ultima-html
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/share-svc
|
||||
# Bind to loopback only; Caddy reverse-proxies public traffic to it.
|
||||
Environment=ADDR=127.0.0.1:8090
|
||||
Environment=DB_PATH=/var/lib/share-svc/shares.db
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
# Run as an ephemeral, unprivileged user. StateDirectory creates and chowns
|
||||
# /var/lib/share-svc so the SQLite file persists across restarts.
|
||||
DynamicUser=yes
|
||||
StateDirectory=share-svc
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6
|
||||
RestrictNamespaces=yes
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
15
shell.nix
Normal file
15
shell.nix
Normal file
@@ -0,0 +1,15 @@
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
|
||||
pkgs.mkShellNoCC {
|
||||
packages = with pkgs; [
|
||||
go
|
||||
gopls
|
||||
just
|
||||
nodejs
|
||||
starship
|
||||
typescript-language-server
|
||||
];
|
||||
shellHook = ''
|
||||
eval "$(starship init bash)"
|
||||
'';
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import "./fabula-ultima-sheet.css";
|
||||
import spellsFile from "../data/spells.yml";
|
||||
|
||||
const STATUSES = ["Slow", "Enraged", "Dazed", "Weak", "Poisoned", "Shaken"];
|
||||
const FEELINGS = [
|
||||
@@ -77,8 +78,9 @@ interface ClassEntry {
|
||||
|
||||
interface Spell {
|
||||
name: string;
|
||||
spellClass: string;
|
||||
notes: string;
|
||||
mp: string | number;
|
||||
mp: string;
|
||||
targets: string;
|
||||
duration: string;
|
||||
}
|
||||
@@ -133,22 +135,37 @@ const BLANK_BONDS: Bond[] = Array.from({ length: 6 }, () => ({
|
||||
feelings: [],
|
||||
}));
|
||||
|
||||
// URL-safe base64 (RFC 4648 §5): avoids +, /, = so the result needs no
|
||||
// percent-encoding in a query string. Accepts standard base64 on decode too,
|
||||
// so links shared before this change still load.
|
||||
function bytesToBase64Url(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function base64UrlToBytes(b64: string) {
|
||||
let std = b64.replace(/-/g, "+").replace(/_/g, "/");
|
||||
if (std.length % 4) std += "=".repeat(4 - (std.length % 4));
|
||||
return Uint8Array.from(atob(std), (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function compressToBase64(str: string) {
|
||||
const stream = new CompressionStream("deflate-raw");
|
||||
const writer = stream.writable.getWriter();
|
||||
writer.write(new TextEncoder().encode(str));
|
||||
writer.close();
|
||||
const buf = await new Response(stream.readable).arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
return bytesToBase64Url(new Uint8Array(buf));
|
||||
}
|
||||
|
||||
async function decompressFromBase64(b64: string) {
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
const bytes = base64UrlToBytes(b64);
|
||||
const stream = new DecompressionStream("deflate-raw");
|
||||
const writer = stream.writable.getWriter();
|
||||
writer.write(bytes);
|
||||
@@ -160,6 +177,51 @@ async function decompressFromBase64(b64: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Drop empty strings and empty arrays before encoding: a sheet is mostly blank
|
||||
// text fields, and applyData() restores any missing key to its default.
|
||||
function pruneEmpty(data: Record<string, unknown>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(data).filter(
|
||||
([, v]) => v !== "" && !(Array.isArray(v) && v.length === 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve a share-service path relative to the current page so it works under
|
||||
// any base path (e.g. /fabula/) where Caddy reverse-proxies /api/* to the
|
||||
// backend. Returns e.g. https://host/fabula/api/s.
|
||||
function apiURL(path: string) {
|
||||
return new URL(path, window.location.href).toString();
|
||||
}
|
||||
|
||||
function autoResize(el: HTMLTextAreaElement) {
|
||||
el.style.height = "auto";
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}
|
||||
|
||||
// Copy text to the clipboard. navigator.clipboard is only available in a
|
||||
// secure context (HTTPS or localhost); over plain HTTP it's undefined, so we
|
||||
// fall back to the deprecated execCommand("copy"), which still works there.
|
||||
// Must be called from within a user gesture (e.g. a click handler).
|
||||
async function copyToClipboard(text: string) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} finally {
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
|
||||
export default function CharacterSheet() {
|
||||
const [activeTab, setActiveTab] = useState("main");
|
||||
const [urlMode, setUrlMode] = useState(false);
|
||||
@@ -172,6 +234,7 @@ export default function CharacterSheet() {
|
||||
const [primaryClasses, setPrimaryClasses] = useState<ClassEntry[]>([]);
|
||||
const [otherClasses, setOtherClasses] = useState<ClassEntry[]>([]);
|
||||
const [spells, setSpells] = useState<Spell[]>([]);
|
||||
const [spellPickerOpen, setSpellPickerOpen] = useState(false);
|
||||
const [statuses, setStatuses] = useState<CheckMap>({});
|
||||
const [martial, setMartial] = useState<CheckMap>({});
|
||||
const [disciplines, setDisciplines] = useState<CheckMap>({});
|
||||
@@ -255,6 +318,7 @@ export default function CharacterSheet() {
|
||||
oc: otherClasses.map((c) => ({ n: c.name, b: c.benefits, s: c.skills })),
|
||||
sp: spells.map((s) => ({
|
||||
n: s.name,
|
||||
cl: s.spellClass,
|
||||
nt: s.notes,
|
||||
mp: s.mp,
|
||||
tg: s.targets,
|
||||
@@ -362,6 +426,7 @@ export default function CharacterSheet() {
|
||||
setSpells(
|
||||
rawSpells.map((s: SavedData) => ({
|
||||
name: s.n ?? s.name ?? "",
|
||||
spellClass: s.cl ?? s.spellClass ?? "",
|
||||
notes: s.nt ?? s.notes ?? "",
|
||||
mp: s.mp ?? "",
|
||||
targets: s.tg ?? s.targets ?? "",
|
||||
@@ -372,8 +437,23 @@ export default function CharacterSheet() {
|
||||
|
||||
// Init
|
||||
useEffect(() => {
|
||||
const encoded = new URLSearchParams(window.location.search).get("c");
|
||||
if (encoded) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const shortId = params.get("s");
|
||||
const encoded = params.get("c");
|
||||
if (shortId) {
|
||||
// Short link: fetch the sheet JSON from the share service.
|
||||
fetch(apiURL("api/s/" + encodeURIComponent(shortId)))
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
applyData(data);
|
||||
setUrlMode(true);
|
||||
})
|
||||
.catch(() => tryAutoLoad());
|
||||
} else if (encoded) {
|
||||
// Legacy/offline self-contained link with the data inline.
|
||||
decompressFromBase64(encoded)
|
||||
.then((json) => {
|
||||
applyData(JSON.parse(json));
|
||||
@@ -452,13 +532,24 @@ export default function CharacterSheet() {
|
||||
);
|
||||
|
||||
const copyShareURL = useCallback(async () => {
|
||||
const encoded = await compressToBase64(JSON.stringify(collectData()));
|
||||
await navigator.clipboard.writeText(
|
||||
window.location.origin +
|
||||
window.location.pathname +
|
||||
"?c=" +
|
||||
encodeURIComponent(encoded),
|
||||
);
|
||||
const json = JSON.stringify(pruneEmpty(collectData()));
|
||||
const base = window.location.origin + window.location.pathname;
|
||||
let shareURL: string;
|
||||
try {
|
||||
// Preferred: store the sheet server-side and share a short ?s=<id> link.
|
||||
const res = await fetch(apiURL("api/s"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: json,
|
||||
});
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const { id } = await res.json();
|
||||
shareURL = base + "?s=" + id;
|
||||
} catch {
|
||||
// Backend unreachable: fall back to a self-contained inline ?c= link.
|
||||
shareURL = base + "?c=" + (await compressToBase64(json));
|
||||
}
|
||||
await copyToClipboard(shareURL);
|
||||
setCopyStatus(true);
|
||||
setTimeout(() => setCopyStatus(false), 2000);
|
||||
}, [collectData]);
|
||||
@@ -1090,6 +1181,8 @@ export default function CharacterSheet() {
|
||||
<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) =>
|
||||
@@ -1239,6 +1332,7 @@ export default function CharacterSheet() {
|
||||
<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>
|
||||
@@ -1247,101 +1341,165 @@ export default function CharacterSheet() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{spells.map((s, i) => (
|
||||
<tr key={i}>
|
||||
<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,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Notes / effect description…"
|
||||
value={s.notes || ""}
|
||||
onChange={(e) =>
|
||||
setSpells((prev) =>
|
||||
prev.map((sp, j) =>
|
||||
j === i ? { ...sp, notes: e.target.value } : sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="spell-mp-col">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={s.mp || ""}
|
||||
onChange={(e) =>
|
||||
setSpells((prev) =>
|
||||
prev.map((sp, j) =>
|
||||
j === i ? { ...sp, mp: e.target.value } : sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
style={{ minHeight: 32 }}
|
||||
/>
|
||||
</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
|
||||
className="spell-del-btn"
|
||||
onClick={() =>
|
||||
setSpells((prev) => prev.filter((_, j) => j !== i))
|
||||
}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<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
|
||||
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>
|
||||
<button
|
||||
className="add-btn"
|
||||
style={{ marginTop: 10 }}
|
||||
onClick={() =>
|
||||
setSpells((prev) => [
|
||||
...prev,
|
||||
{ name: "", notes: "", mp: "", targets: "", duration: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
+ Add Spell / Arcana
|
||||
</button>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 10 }}>
|
||||
<button
|
||||
className="add-btn"
|
||||
onClick={() =>
|
||||
setSpells((prev) => [
|
||||
...prev,
|
||||
{ name: "", spellClass: "", notes: "", mp: "", targets: "", duration: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
+ Add Spell / Arcana
|
||||
</button>
|
||||
<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 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">
|
||||
|
||||
@@ -765,12 +765,37 @@ input[type="number"] {
|
||||
min-height: 55px;
|
||||
}
|
||||
|
||||
.spell-inputs-row td {
|
||||
border-bottom: none;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.spell-notes-row td {
|
||||
padding-top: 0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.spell-notes-row textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.spell-name-col {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
.spell-name-col input {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.spell-class-col {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.spell-mp-col {
|
||||
width: 12%;
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.spell-targets-col {
|
||||
@@ -782,7 +807,8 @@ input[type="number"] {
|
||||
}
|
||||
|
||||
.spell-del-col {
|
||||
width: 40px;
|
||||
width: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.spell-del-btn {
|
||||
@@ -821,6 +847,82 @@ input[type="number"] {
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.spell-picker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spell-picker-modal {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border-bright);
|
||||
width: min(480px, 90vw);
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.spell-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-bright);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--teal);
|
||||
}
|
||||
|
||||
.spell-picker-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.spell-picker-close:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.spell-picker-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.spell-picker-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.spell-picker-item:hover {
|
||||
background: var(--surface3);
|
||||
}
|
||||
|
||||
.spell-picker-name {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.spell-picker-class {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* ── DISCIPLINES ─────────────────────────────────── */
|
||||
.disciplines-row {
|
||||
display: flex;
|
||||
|
||||
19
src/globals.d.ts
vendored
19
src/globals.d.ts
vendored
@@ -1,5 +1,24 @@
|
||||
declare module "*.css";
|
||||
|
||||
declare module "*.yml" {
|
||||
const data: unknown;
|
||||
export default data;
|
||||
}
|
||||
|
||||
interface SpellTemplate {
|
||||
name: string;
|
||||
cost: string;
|
||||
targets: string;
|
||||
duration: string;
|
||||
description: string;
|
||||
class: string;
|
||||
offensive?: boolean;
|
||||
}
|
||||
|
||||
interface SpellsFile {
|
||||
spells: SpellTemplate[];
|
||||
}
|
||||
|
||||
interface BookPage {
|
||||
n: number;
|
||||
content: string;
|
||||
|
||||
@@ -65,6 +65,10 @@ module.exports = (env, argv) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.ya?ml$/,
|
||||
use: "yaml-loader",
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
|
||||
Reference in New Issue
Block a user