Compare commits
No commits in common. "9607462a7c6843e361200f6f8cd9a3d213c7a926" and "0cf3e4583c8bf6978de65321347cb8be85a60b73" have entirely different histories.
9607462a7c
...
0cf3e4583c
12 changed files with 25 additions and 347 deletions
|
|
@ -9,11 +9,6 @@ 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,58 +153,3 @@
|
||||||
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,7 +22,6 @@ 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({
|
||||||
|
|
@ -32,42 +31,26 @@ 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 className={cardClass} onClick={() => onClick(character.id)} style={cardStyle}>
|
<div
|
||||||
|
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}>
|
||||||
{isDead ? "\u{1F480} " : ""}{character.name}
|
{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}
|
||||||
|
|
@ -78,16 +61,13 @@ 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={isDead ? () => {} : (hp) => onHpChange(character.id, hp)}
|
onChange={(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}>{calculateAC(character).effective}</span>
|
<span className={styles.acValue}>
|
||||||
|
{calculateAC(character).effective}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={styles.luck}
|
className={styles.luck}
|
||||||
|
|
@ -106,18 +86,6 @@ 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,28 +302,3 @@
|
||||||
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,10 +56,6 @@ 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,
|
||||||
|
|
@ -123,7 +119,6 @@ export default function InitiativeTracker({
|
||||||
onAddEnemy={emitAddEnemy}
|
onAddEnemy={emitAddEnemy}
|
||||||
onNext={emitNext}
|
onNext={emitNext}
|
||||||
onEnd={emitEnd}
|
onEnd={emitEnd}
|
||||||
onRecoveryRoll={emitRecoveryRoll}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -237,7 +232,6 @@ interface ActivePhaseProps {
|
||||||
onAddEnemy: () => void;
|
onAddEnemy: () => void;
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
onEnd: () => void;
|
onEnd: () => void;
|
||||||
onRecoveryRoll: (characterId: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivePhase({
|
function ActivePhase({
|
||||||
|
|
@ -255,7 +249,6 @@ function ActivePhase({
|
||||||
onAddEnemy,
|
onAddEnemy,
|
||||||
onNext,
|
onNext,
|
||||||
onEnd,
|
onEnd,
|
||||||
onRecoveryRoll,
|
|
||||||
}: ActivePhaseProps) {
|
}: ActivePhaseProps) {
|
||||||
const partyActive = combat.current_side === "party";
|
const partyActive = combat.current_side === "party";
|
||||||
|
|
||||||
|
|
@ -270,30 +263,14 @@ function ActivePhase({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{partyChars.map((c) => {
|
{partyChars.map((c) => (
|
||||||
const dyingCondition = c.conditions?.find((cond) => cond.name === "Dying");
|
<div key={c.id} className={styles.combatantRow}>
|
||||||
return (
|
<span className={styles.dot} style={{ background: c.color }} />
|
||||||
<div key={c.id} className={styles.combatantRow}>
|
<span className={partyActive ? styles.activeName : styles.rollName}>
|
||||||
<span className={styles.dot} style={{ background: c.color }} />
|
{c.name}
|
||||||
<span className={partyActive ? styles.activeName : styles.rollName}>
|
</span>
|
||||||
{c.is_dead ? "\u{1F480} " : ""}{c.name}
|
</div>
|
||||||
</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,8 +312,7 @@ export default function CampaignView() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleHpChange(characterId: number, hp: number) {
|
async function handleHpChange(characterId: number, hp: number) {
|
||||||
const clampedHp = Math.max(0, hp);
|
await updateCharacter(characterId, { hp_current: hp });
|
||||||
await updateCharacter(characterId, { hp_current: clampedHp });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStatChange(
|
async function handleStatChange(
|
||||||
|
|
@ -454,7 +453,6 @@ 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-12
|
**Last updated:** 2026-04-11
|
||||||
|
|
||||||
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,19 +104,6 @@ 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.
|
||||||
|
|
@ -126,7 +113,8 @@ 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
|
||||||
- **Conditions / status effects** — poisoned, stunned, etc. shown on character cards (dying is already tracked internally)
|
- **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, 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,17 +721,9 @@ 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 [charRow] = await db.execute<RowDataPacket[]>(
|
const io = req.app.get("io");
|
||||||
"SELECT * FROM characters WHERE id = ?",
|
const char = await getCharacterCampaignId(Number(req.params.id));
|
||||||
[req.params.id]
|
io.to(String(char.campaign_id)).emit("character:rested", { characterId: Number(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,109 +154,6 @@ 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
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 910 KiB |
|
|
@ -145,23 +145,6 @@
|
||||||
</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,45 +245,6 @@ 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() {
|
||||||
|
|
@ -312,7 +273,6 @@ 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