Compare commits
10 commits
0cf3e4583c
...
9607462a7c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9607462a7c | ||
|
|
b7b4123f8e | ||
|
|
4b0339649b | ||
|
|
6114aef307 | ||
|
|
7cfe677126 | ||
|
|
bf7db6bd4c | ||
|
|
52f792d63b | ||
|
|
d39d6fa9b7 | ||
|
|
f853fdbce3 | ||
|
|
43a5b85dcf |
12 changed files with 347 additions and 25 deletions
|
|
@ -9,6 +9,11 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-04-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Death timer** — when a character drops to 0 HP they enter a dying state with a 1d4 + CON modifier round countdown (minimum 1). The character card gains a pulsing red border and shows the rounds remaining. Each party turn the timer ticks down automatically when initiative advances. The DM can roll a d20 recovery save from the initiative tracker; an 18 or higher lets the character stand at 1 HP. When the timer expires the character is marked permanently dead (greyed-out card, locked HP). A DM-only Revive button resets the character to 1 HP if story reasons bring them back.
|
||||||
|
|
||||||
## [0.3.0] - 2026-04-11
|
## [0.3.0] - 2026-04-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -153,3 +153,58 @@
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dying {
|
||||||
|
border: 2px solid var(--danger) !important;
|
||||||
|
animation: dyingPulse 1.5s ease-in-out infinite;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dyingPulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(var(--shadow-rgb), 0.5),
|
||||||
|
0 1px 4px rgba(var(--shadow-rgb), 0.3),
|
||||||
|
inset 0 1px 0 rgba(var(--gold-rgb), 0.06),
|
||||||
|
0 0 6px rgba(var(--danger-rgb), 0.4);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(var(--shadow-rgb), 0.5),
|
||||||
|
0 1px 4px rgba(var(--shadow-rgb), 0.3),
|
||||||
|
inset 0 1px 0 rgba(var(--gold-rgb), 0.06),
|
||||||
|
0 0 20px rgba(var(--danger-rgb), 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dead {
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: grayscale(0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dyingLabel {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviveBtn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--gold);
|
||||||
|
color: var(--gold);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: "Cinzel", Georgia, serif;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviveBtn:hover {
|
||||||
|
background: rgba(var(--gold-rgb), 0.12);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ interface CharacterCardProps {
|
||||||
onClick: (characterId: number) => void;
|
onClick: (characterId: number) => void;
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
focusSpell?: string;
|
focusSpell?: string;
|
||||||
|
isDM?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CharacterCard({
|
export default function CharacterCard({
|
||||||
|
|
@ -31,26 +32,42 @@ export default function CharacterCard({
|
||||||
onClick,
|
onClick,
|
||||||
canEdit = true,
|
canEdit = true,
|
||||||
focusSpell,
|
focusSpell,
|
||||||
|
isDM = false,
|
||||||
}: CharacterCardProps) {
|
}: CharacterCardProps) {
|
||||||
|
const dyingCondition = character.conditions?.find((c) => c.name === "Dying");
|
||||||
|
const isDying = !!dyingCondition;
|
||||||
|
const isDead = !!character.is_dead;
|
||||||
|
|
||||||
|
const cardClass = [
|
||||||
|
styles.card,
|
||||||
|
isDying ? styles.dying : "",
|
||||||
|
isDead ? styles.dead : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
// When dying/dead the left-color border is replaced by the dying/dead CSS
|
||||||
|
const cardStyle = isDying || isDead
|
||||||
|
? {}
|
||||||
|
: { borderLeftColor: character.color, borderLeftWidth: "3px" };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cardClass} onClick={() => onClick(character.id)} style={cardStyle}>
|
||||||
className={styles.card}
|
|
||||||
onClick={() => onClick(character.id)}
|
|
||||||
style={{ borderLeftColor: character.color, borderLeftWidth: "3px" }}
|
|
||||||
>
|
|
||||||
<div className={styles.cardHeader}>
|
<div className={styles.cardHeader}>
|
||||||
<img className={styles.avatar} src={getAvatarUrl(character)} alt="" />
|
<img className={styles.avatar} src={getAvatarUrl(character)} alt="" />
|
||||||
<div className={styles.nameRow}>
|
<div className={styles.nameRow}>
|
||||||
<span className={styles.name}>
|
<span className={styles.name}>
|
||||||
{character.name}
|
{isDead ? "\u{1F480} " : ""}{character.name}
|
||||||
{character.title ? ` ${character.title}` : ""}
|
{character.title ? ` ${character.title}` : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.level}>Lvl {character.level}</span>
|
<span className={styles.level}>Lvl {character.level}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.meta}>
|
<div className={styles.meta}>
|
||||||
{character.ancestry} {character.class}
|
{character.ancestry} {character.class}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{focusSpell && (
|
{focusSpell && (
|
||||||
<div className={styles.focusIndicator}>
|
<div className={styles.focusIndicator}>
|
||||||
● Focusing: {focusSpell}
|
● Focusing: {focusSpell}
|
||||||
|
|
@ -61,13 +78,16 @@ export default function CharacterCard({
|
||||||
<HpBar
|
<HpBar
|
||||||
current={character.hp_current}
|
current={character.hp_current}
|
||||||
max={character.hp_max + getTalentHpBonus(character)}
|
max={character.hp_max + getTalentHpBonus(character)}
|
||||||
onChange={(hp) => onHpChange(character.id, hp)}
|
onChange={isDead ? () => {} : (hp) => onHpChange(character.id, hp)}
|
||||||
/>
|
/>
|
||||||
|
{isDying && dyingCondition && (
|
||||||
|
<span className={styles.dyingLabel} title="Dying">
|
||||||
|
{"\u{1F480}"} {dyingCondition.rounds_remaining}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className={styles.ac}>
|
<div className={styles.ac}>
|
||||||
<span className={styles.acLabel}>AC</span>
|
<span className={styles.acLabel}>AC</span>
|
||||||
<span className={styles.acValue}>
|
<span className={styles.acValue}>{calculateAC(character).effective}</span>
|
||||||
{calculateAC(character).effective}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={styles.luck}
|
className={styles.luck}
|
||||||
|
|
@ -86,6 +106,18 @@ export default function CharacterCard({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isDead && isDM && (
|
||||||
|
<button
|
||||||
|
className={styles.reviveBtn}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpdate(character.id, { is_dead: false, hp_current: 1 } as Partial<Character>);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Revive
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.modRow}>
|
<div className={styles.modRow}>
|
||||||
{STATS.map((stat) => {
|
{STATS.map((stat) => {
|
||||||
const value = getEffectiveStat(character, stat);
|
const value = getEffectiveStat(character, stat);
|
||||||
|
|
|
||||||
|
|
@ -302,3 +302,28 @@
|
||||||
border-color: rgba(var(--gold-rgb), 0.5);
|
border-color: rgba(var(--gold-rgb), 0.5);
|
||||||
color: var(--gold);
|
color: var(--gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dyingTag {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recoveryBtn {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recoveryBtn:hover {
|
||||||
|
background: rgba(var(--danger-rgb), 0.12);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ export default function InitiativeTracker({
|
||||||
socket.emit("initiative:end", { campaignId, combatId: combat.id });
|
socket.emit("initiative:end", { campaignId, combatId: combat.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitRecoveryRoll(characterId: number) {
|
||||||
|
socket.emit("death:recovery-roll", { campaignId, characterId });
|
||||||
|
}
|
||||||
|
|
||||||
function emitUpdateEnemyHp(enemyId: string, hp_current: number) {
|
function emitUpdateEnemyHp(enemyId: string, hp_current: number) {
|
||||||
socket.emit("initiative:update-enemy", {
|
socket.emit("initiative:update-enemy", {
|
||||||
campaignId,
|
campaignId,
|
||||||
|
|
@ -119,6 +123,7 @@ export default function InitiativeTracker({
|
||||||
onAddEnemy={emitAddEnemy}
|
onAddEnemy={emitAddEnemy}
|
||||||
onNext={emitNext}
|
onNext={emitNext}
|
||||||
onEnd={emitEnd}
|
onEnd={emitEnd}
|
||||||
|
onRecoveryRoll={emitRecoveryRoll}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -232,6 +237,7 @@ interface ActivePhaseProps {
|
||||||
onAddEnemy: () => void;
|
onAddEnemy: () => void;
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
onEnd: () => void;
|
onEnd: () => void;
|
||||||
|
onRecoveryRoll: (characterId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivePhase({
|
function ActivePhase({
|
||||||
|
|
@ -249,6 +255,7 @@ function ActivePhase({
|
||||||
onAddEnemy,
|
onAddEnemy,
|
||||||
onNext,
|
onNext,
|
||||||
onEnd,
|
onEnd,
|
||||||
|
onRecoveryRoll,
|
||||||
}: ActivePhaseProps) {
|
}: ActivePhaseProps) {
|
||||||
const partyActive = combat.current_side === "party";
|
const partyActive = combat.current_side === "party";
|
||||||
|
|
||||||
|
|
@ -263,14 +270,30 @@ function ActivePhase({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{partyChars.map((c) => (
|
{partyChars.map((c) => {
|
||||||
<div key={c.id} className={styles.combatantRow}>
|
const dyingCondition = c.conditions?.find((cond) => cond.name === "Dying");
|
||||||
<span className={styles.dot} style={{ background: c.color }} />
|
return (
|
||||||
<span className={partyActive ? styles.activeName : styles.rollName}>
|
<div key={c.id} className={styles.combatantRow}>
|
||||||
{c.name}
|
<span className={styles.dot} style={{ background: c.color }} />
|
||||||
</span>
|
<span className={partyActive ? styles.activeName : styles.rollName}>
|
||||||
</div>
|
{c.is_dead ? "\u{1F480} " : ""}{c.name}
|
||||||
))}
|
</span>
|
||||||
|
{dyingCondition && !c.is_dead && (
|
||||||
|
<span className={styles.dyingTag}>
|
||||||
|
{"\u{1F480}"} Dying ({dyingCondition.rounds_remaining ?? "?"}r)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isDM && dyingCondition && !c.is_dead && (
|
||||||
|
<button
|
||||||
|
className={styles.recoveryBtn}
|
||||||
|
onClick={() => onRecoveryRoll(c.id)}
|
||||||
|
>
|
||||||
|
Roll Recovery
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${styles.section} ${!partyActive ? styles.activeSection : ""}`}>
|
<div className={`${styles.section} ${!partyActive ? styles.activeSection : ""}`}>
|
||||||
|
|
|
||||||
|
|
@ -312,7 +312,8 @@ export default function CampaignView() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleHpChange(characterId: number, hp: number) {
|
async function handleHpChange(characterId: number, hp: number) {
|
||||||
await updateCharacter(characterId, { hp_current: hp });
|
const clampedHp = Math.max(0, hp);
|
||||||
|
await updateCharacter(characterId, { hp_current: clampedHp });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStatChange(
|
async function handleStatChange(
|
||||||
|
|
@ -453,6 +454,7 @@ export default function CampaignView() {
|
||||||
onClick={setSelectedId}
|
onClick={setSelectedId}
|
||||||
canEdit={role === "dm" || char.user_id === user?.userId}
|
canEdit={role === "dm" || char.user_id === user?.userId}
|
||||||
focusSpell={focusSpells.get(char.id)}
|
focusSpell={focusSpells.get(char.id)}
|
||||||
|
isDM={role === "dm"}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Darkwatch Handbook
|
# Darkwatch Handbook
|
||||||
|
|
||||||
**Last updated:** 2026-04-11
|
**Last updated:** 2026-04-12
|
||||||
|
|
||||||
A reference for everyone working on or with Darkwatch — whether you're a DM discovering the tool, a developer joining the project, or a collaborator adding features.
|
A reference for everyone working on or with Darkwatch — whether you're a DM discovering the tool, a developer joining the project, or a collaborator adding features.
|
||||||
|
|
||||||
|
|
@ -104,6 +104,19 @@ Shadowdark uses team-based initiative — the whole party acts together, then al
|
||||||
|
|
||||||
Combat state persists to the database. If the server restarts mid-fight, the tracker reappears when players rejoin.
|
Combat state persists to the database. If the server restarts mid-fight, the tracker reappears when players rejoin.
|
||||||
|
|
||||||
|
### Death Timer
|
||||||
|
|
||||||
|
Shadowdark's dying mechanic is fully implemented. When a character drops to 0 HP they enter a dying state:
|
||||||
|
|
||||||
|
- The server rolls 1d4 + CON modifier (minimum 1) to determine how many rounds the character has left
|
||||||
|
- The character card gains a pulsing red border and shows a 💀 countdown (e.g. 💀 3)
|
||||||
|
- Each time the party's turn begins in the initiative tracker, all dying timers tick down by 1
|
||||||
|
- The DM can click **Roll Recovery** in the initiative tracker — a d20 is rolled server-side; 18 or higher lets the character stand at 1 HP
|
||||||
|
- If the timer reaches 0 the character is marked permanently dead (card goes grey, HP is locked)
|
||||||
|
- The DM can click **Revive** on a dead character's card to bring them back at 1 HP
|
||||||
|
|
||||||
|
Healing a dying character above 0 HP at any point immediately clears the dying state with no recovery roll needed.
|
||||||
|
|
||||||
### DM View
|
### DM View
|
||||||
|
|
||||||
The DM sees all characters in a compact three-column card grid showing: HP (current/max), AC, luck token state, torch timer, and all stat modifiers at a glance. The full character detail is one click away.
|
The DM sees all characters in a compact three-column card grid showing: HP (current/max), AC, luck token state, torch timer, and all stat modifiers at a glance. The full character detail is one click away.
|
||||||
|
|
@ -113,8 +126,7 @@ The DM sees all characters in a compact three-column card grid showing: HP (curr
|
||||||
Features currently planned or in progress:
|
Features currently planned or in progress:
|
||||||
|
|
||||||
- **Talent roll UI** — roll 2d6 on the class talent table when leveling up; currently talents are added manually
|
- **Talent roll UI** — roll 2d6 on the class talent table when leveling up; currently talents are added manually
|
||||||
- **Death timer** — when a character is dying: 1d4 + CON rounds to live, d20 each turn to rise at 1 HP
|
- **Conditions / status effects** — poisoned, stunned, etc. shown on character cards (dying is already tracked internally)
|
||||||
- **Conditions / status effects** — poisoned, stunned, dying, etc. shown on character cards
|
|
||||||
- **Per-enemy dice rolls** — DM triggers attack/damage rolls from individual enemies in the tracker
|
- **Per-enemy dice rolls** — DM triggers attack/damage rolls from individual enemies in the tracker
|
||||||
- **Party loot / shared inventory** — campaign-level shared item pool, DM-managed
|
- **Party loot / shared inventory** — campaign-level shared item pool, DM-managed
|
||||||
- **Creature gallery / NPC system** — saved enemy stat blocks, quick-add to initiative tracker
|
- **Creature gallery / NPC system** — saved enemy stat blocks, quick-add to initiative tracker
|
||||||
|
|
|
||||||
|
|
@ -721,9 +721,17 @@ router.post("/:id/rest", requireAuth, async (req, res, next) => {
|
||||||
"DELETE FROM character_conditions WHERE character_id = ?",
|
"DELETE FROM character_conditions WHERE character_id = ?",
|
||||||
[req.params.id]
|
[req.params.id]
|
||||||
);
|
);
|
||||||
const io = req.app.get("io");
|
const [charRow] = await db.execute<RowDataPacket[]>(
|
||||||
const char = await getCharacterCampaignId(Number(req.params.id));
|
"SELECT * FROM characters WHERE id = ?",
|
||||||
io.to(String(char.campaign_id)).emit("character:rested", { characterId: Number(req.params.id) });
|
[req.params.id]
|
||||||
|
);
|
||||||
|
const io: Server = req.app.get("io");
|
||||||
|
broadcastToCampaign(io, Number(charRow[0].campaign_id), "character:updated", {
|
||||||
|
...charRow[0],
|
||||||
|
overrides: parseJson(charRow[0].overrides),
|
||||||
|
conditions: [],
|
||||||
|
});
|
||||||
|
io.to(String(charRow[0].campaign_id)).emit("character:rested", { characterId: Number(req.params.id) });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,109 @@ export function setupSocket(io: Server) {
|
||||||
io.to(`campaign:${campaignId}`).emit("atmosphere:update", atmosphere);
|
io.to(`campaign:${campaignId}`).emit("atmosphere:update", atmosphere);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on("death:recovery-roll", async (data: {
|
||||||
|
campaignId: number;
|
||||||
|
characterId: number;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const userId = socket.data.user?.userId;
|
||||||
|
|
||||||
|
// Verify caller is DM
|
||||||
|
const [memberRows] = await db.execute<RowDataPacket[]>(
|
||||||
|
"SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?",
|
||||||
|
[data.campaignId, userId]
|
||||||
|
);
|
||||||
|
if (memberRows.length === 0 || memberRows[0].role !== "dm") return;
|
||||||
|
|
||||||
|
// Verify character has a Dying condition
|
||||||
|
const [dyingRows] = await db.execute<RowDataPacket[]>(
|
||||||
|
"SELECT id FROM character_conditions WHERE character_id = ? AND name = 'Dying'",
|
||||||
|
[data.characterId]
|
||||||
|
);
|
||||||
|
if (dyingRows.length === 0) return;
|
||||||
|
|
||||||
|
// Verify character is not permanently dead
|
||||||
|
const [deadCheck] = await db.execute<RowDataPacket[]>(
|
||||||
|
"SELECT is_dead FROM characters WHERE id = ?",
|
||||||
|
[data.characterId]
|
||||||
|
);
|
||||||
|
if (deadCheck.length === 0 || deadCheck[0].is_dead) return;
|
||||||
|
|
||||||
|
// Get character info for roll log
|
||||||
|
const [charRows] = await db.execute<RowDataPacket[]>(
|
||||||
|
"SELECT name, color, campaign_id FROM characters WHERE id = ?",
|
||||||
|
[data.characterId]
|
||||||
|
);
|
||||||
|
if (charRows.length === 0) return;
|
||||||
|
const char = charRows[0];
|
||||||
|
|
||||||
|
if (char.campaign_id !== data.campaignId) return;
|
||||||
|
|
||||||
|
// Roll d20 server-side
|
||||||
|
const result = rollDice("1d20");
|
||||||
|
const roll = result.total;
|
||||||
|
const nat20 = roll === 20;
|
||||||
|
const success = roll >= 18;
|
||||||
|
|
||||||
|
// Log to roll_log with "Death Save" label (success gets suffix)
|
||||||
|
const label = success ? "Death Save \u2014 stands at 1 HP!" : "Death Save";
|
||||||
|
|
||||||
|
const [insertResult] = await db.execute<import("mysql2").ResultSetHeader>(
|
||||||
|
`INSERT INTO roll_log
|
||||||
|
(campaign_id, character_id, character_name, character_color, type, subtype, label,
|
||||||
|
dice_expression, rolls, modifier, total, advantage, disadvantage, nat20)
|
||||||
|
VALUES (?, ?, ?, ?, 'custom', 'death-save', ?, '1d20', ?, 0, ?, 0, 0, ?)`,
|
||||||
|
[
|
||||||
|
data.campaignId,
|
||||||
|
data.characterId,
|
||||||
|
char.name,
|
||||||
|
char.color,
|
||||||
|
label,
|
||||||
|
JSON.stringify(result.rolls),
|
||||||
|
roll,
|
||||||
|
nat20 ? 1 : 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [savedRows] = await db.execute<RowDataPacket[]>(
|
||||||
|
"SELECT * FROM roll_log WHERE id = ?",
|
||||||
|
[insertResult.insertId]
|
||||||
|
);
|
||||||
|
|
||||||
|
io.to(`campaign:${data.campaignId}`).emit("roll:result", {
|
||||||
|
...savedRows[0],
|
||||||
|
rolls: result.rolls,
|
||||||
|
advantage: false,
|
||||||
|
disadvantage: false,
|
||||||
|
nat20,
|
||||||
|
});
|
||||||
|
|
||||||
|
// On 18+: heal to 1 HP and clear Dying condition
|
||||||
|
if (success) {
|
||||||
|
await db.execute("UPDATE characters SET hp_current = 1 WHERE id = ?", [data.characterId]);
|
||||||
|
await db.execute(
|
||||||
|
"DELETE FROM character_conditions WHERE character_id = ? AND name = 'Dying'",
|
||||||
|
[data.characterId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [updatedChar] = await db.execute<RowDataPacket[]>(
|
||||||
|
"SELECT * FROM characters WHERE id = ?",
|
||||||
|
[data.characterId]
|
||||||
|
);
|
||||||
|
const [updatedConditions] = await db.execute<RowDataPacket[]>(
|
||||||
|
"SELECT * FROM character_conditions WHERE character_id = ?",
|
||||||
|
[data.characterId]
|
||||||
|
);
|
||||||
|
io.to(`campaign:${data.campaignId}`).emit("character:updated", {
|
||||||
|
...updatedChar[0],
|
||||||
|
conditions: updatedConditions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[death:recovery-roll]", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
socket.on("disconnect", () => {
|
socket.on("disconnect", () => {
|
||||||
// Rooms are cleaned up automatically by Socket.IO
|
// Rooms are cleaned up automatically by Socket.IO
|
||||||
});
|
});
|
||||||
|
|
|
||||||
BIN
site/assets/screenshots/death-timer.png
Normal file
BIN
site/assets/screenshots/death-timer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 910 KiB |
|
|
@ -145,6 +145,23 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 8: text left, screenshot right -->
|
||||||
|
<div class="feature-row reverse">
|
||||||
|
<div class="feature-image">
|
||||||
|
<img src="assets/screenshots/death-timer.png" alt="Initiative tracker showing a dying character with skull countdown and Roll Recovery button">
|
||||||
|
</div>
|
||||||
|
<div class="feature-text">
|
||||||
|
<div class="feature-label">Death Timer</div>
|
||||||
|
<h2 class="feature-heading">Every round could be the last.</h2>
|
||||||
|
<p class="feature-desc">
|
||||||
|
When a character drops to 0 HP, a countdown begins: 1d4 + CON modifier rounds
|
||||||
|
before permanent death. The DM sees the timer on the card and can roll a d20
|
||||||
|
recovery save from the initiative tracker — an 18 or higher lets the character
|
||||||
|
stand at 1 HP. Run out the clock and they're gone for good.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Torch + Luck callout cards (no screenshot) -->
|
<!-- Torch + Luck callout cards (no screenshot) -->
|
||||||
<div class="callout-row">
|
<div class="callout-row">
|
||||||
<div class="callout-card">
|
<div class="callout-card">
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,45 @@ async function captureInitiativeActive(page) {
|
||||||
// Screenshot captures the initiative tracker with party + enemies rolled
|
// Screenshot captures the initiative tracker with party + enemies rolled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function captureDeathTimer(page) {
|
||||||
|
await resetToCleanCampaign(page);
|
||||||
|
|
||||||
|
// Start combat with an enemy so the initiative tracker is visible
|
||||||
|
await page.click('button:has-text("Combat")');
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
const enemyNameInput = page.locator('input[placeholder*="Goblin"], input[placeholder*="enemy" i], input[placeholder*="name" i]').first();
|
||||||
|
if (await enemyNameInput.isVisible().catch(() => false)) {
|
||||||
|
await enemyNameInput.fill('Skeleton');
|
||||||
|
const hpInput = page.locator('input[placeholder*="HP"], input[placeholder*="hp" i], input[type="number"]').first();
|
||||||
|
if (await hpInput.isVisible().catch(() => false)) {
|
||||||
|
await hpInput.fill('8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roll initiative to enter the active phase
|
||||||
|
await page.click('button:has-text("Roll Initiative")');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// Bring the first character to 0 HP via the API — server rolls dying timer automatically
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const campaignId = Number(location.pathname.match(/\/campaign\/(\d+)/)?.[1]);
|
||||||
|
const res = await fetch(`/api/campaigns/${campaignId}/characters`, { credentials: 'include' });
|
||||||
|
const chars = await res.json();
|
||||||
|
if (chars.length > 0) {
|
||||||
|
await fetch(`/api/characters/${chars[0].id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ hp_current: 0 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(1500); // wait for socket broadcast + React re-render
|
||||||
|
// Screenshot shows initiative tracker with dying character (skull tag + Roll Recovery button)
|
||||||
|
// and DM card with pulsing red border + skull countdown
|
||||||
|
}
|
||||||
|
|
||||||
// ── main ───────────────────────────────────────────────────────────────────
|
// ── main ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
|
|
@ -273,6 +312,7 @@ async function run() {
|
||||||
await shot(page, 'spellcasting', captureSpellcasting);
|
await shot(page, 'spellcasting', captureSpellcasting);
|
||||||
await shot(page, 'atmosphere', captureAtmosphere);
|
await shot(page, 'atmosphere', captureAtmosphere);
|
||||||
await shot(page, 'initiative-active', captureInitiativeActive);
|
await shot(page, 'initiative-active', captureInitiativeActive);
|
||||||
|
await shot(page, 'death-timer', captureDeathTimer);
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue