10 Commits
Author SHA1 Message Date
uberwald be43913ae3 fix(i18n): synchronise et complète les clés FR/EN
Release Creation / build (release) Failing after 1m17s
- EN : ajoute les clés top-level NAME/NONE/ORIGIN/PROFILE/THEME/TOTEM
  utilisées par les fiches PNJ (légacy et appv2), VERMINE.skills/skill/
  specificLevel/competence/modes/traits_selector, VERMINE.add/delete/
  learning, COMBAT.Begin/Delete/End/NotStarted/TurnEnd.
- FR : ajoute VERMINE.add/delete/learning et COMBAT.Delete.
- Rareté alignée : EN rarity_1 Peu commun/Uncommon, rarity_2 Rare,
  rarity_3 Très rare/Very rare (était décalée).
- Chat cards ability/rite : UI.effects (objet) -> UI.effects.name.
2026-08-09 15:00:57 +02:00
uberwald f9ef1d48a4 chore(lang): supprime la clé i18n morte VERMINE.group
Plus aucun libellé ne localise VERMINE.group en tant que string : le
libellé du type d'acteur Groupe vient de VERMINE.Sheet.group et
VERMINE.type_group, et LOCALIZATION_PREFIXES du modèle Groupe ne
consomme pas cette clé scalaire.
2026-08-09 14:54:35 +02:00
uberwald 5be1365038 feat(dialogs): renomme la case Groupe en « Bonus supplémentaire »
Le champ numérique de bonus (+ND) était étiqueté « Groupe », confondu
avec le Groupe de joueurs et la Réserve de Groupe réelle (affichée à
part). Nouvelle clé i18n bonus_supplementary utilisée dans les 3
dialogues de jet et le détail du pool du message. VERMINE.group reste
réservé au type d'acteur Groupe.
2026-08-09 13:42:45 +02:00
uberwald 89413c17a4 feat(effort): dépense des points d'Effort sur un test (règles p.35)
Le bouton d'Effort du message de jet déduit désormais les points de la
Réserve d'Effort de l'acteur (1 point = 1 relance), s'ajoute aux relances
de Compétence au lieu de les remplacer, et affiche le pool restant. Le
curseur est plafonné à min(caractéristique, effort disponible) et la
section est masquée pour les acteurs sans Réserve d'Effort (PNJ,
créatures, effort vide).
2026-08-09 09:32:59 +02:00
uberwald 15f8e927f0 fix(item): ouverture du dialogue de test de fiabilité
ReliabilityDialog est exporté en défaut mais la fiche d'item le
destructurait comme export nommé, d'où undefined et un bouton inopérant
pour armes, protections et objets.
2026-08-09 09:26:17 +02:00
uberwald 7382723e75 fix(combat): blessures appliquées au seul acteur du token ciblé
Résout par uuid avant l'id d'acteur : pour un token non lié (PNJ mineur),
token.actor.id expose encore l'id de l'acteur monde dont il est issu, et
résoudre par id blessait alors tous les tokens de la fiche. Le uuid d'un
acteur synthétique est résolu en extrayant directement la scène et le
token, car fromUuid échoue sur les documents non indexés quand la
résolution est asynchrone.
2026-08-09 09:14:09 +02:00
uberwald 3a84ef09f6 feat(combat): sélecteur de spécialité dans le dialogue d'attaque
- Le template combat-dialog.hbs n'affichait pas les spécialités alors que
  combatDialog.mjs les préparait : #hasSpecialtySelected() ne pouvait
  jamais être vrai, le +1D spécialité n'était jamais appliqué aux attaques
- Affichage de la section spécialités (radio buttons) limitée aux
  spécialités de la compétence de l'arme (skillKey)
- Affichage de la spécialité sélectionnée dans le résumé du dialogue
  (.current-specialty) et propagation du nom dans le message de jet
2026-08-09 08:54:08 +02:00
uberwald edd1819c86 fix(chat): relance des dés accessible aux joueurs sur Foundry v14
- Correction du check d'autorisation : message.user -> message.author
  (champ renommé en v12+, message.user est undefined sur v14)
- Les joueurs ne peuvent plus mettre à jour les ChatMessages (permission
  OWNER, option Modifier retirée du menu contextuel en v12+) : la relance
  (dé et effort) est relayée via le socket system.vermine2047 et appliquée
  par le client MJ (registerRerollSocket), avec vérification de l'auteur
- Ajout de la déclaration "socket": true au system.json (requise par le
  serveur pour écouter le namespace system.<id>)
2026-08-09 08:31:09 +02:00
uberwald 9ab2c6b01a feat(experience): atout Socle de compétences — +1D gratuit dans le dialogue d'apprentissage 2026-08-09 07:47:56 +02:00
uberwald 0ef0de45ff fix(experience): phase ciblée (groupe/sans groupe/individuel) + dépense des Dés d'Évolution
Release Creation / build (release) Failing after 1m18s
Phase d'Expérience :
- sélecteur de groupe : « Tous les personnages », « Sans groupe » ou un groupe précis → seuls ses membres sont récompensés
- case « Inclure dans cette phase » par personnage pour exclure de la phase
- chaque ligne affiche son groupe ([Groupe] / [Sans groupe])
- les collectifs ne sont versés au groupe que si un vrai groupe est choisi

Dés d'Évolution :
- achat : ne consomme que le coût du niveau (le reste du stock est conservé) au lieu d'effacer tout
- total « après achat » recalculé en direct selon le niveau sélectionné
- libellés d'options nettoyés (1D au lieu de 1D — 1D)
2026-08-08 18:35:01 +02:00
23 changed files with 381 additions and 90 deletions
+13
View File
@@ -61,3 +61,16 @@ make build / watch / lint # Makefile wrappers for above
- **No tests**: `npm test` is a stub (exits 1). No test framework installed.
- **No CI**: `.github/` is gitignored; no workflows configured.
- **HotReload**: `system.json` flags hotReload for css, scss, hbs, json extensions.
## Constraints and remarks
Foundryv14 only : foundryvtt.com/api/
## Tests
With chrome-devtools on port 9222
World is called "Vermine", thru https://localhost:31000, with Gamemaster user logged in (no password needed)
Always try to re-use existing chrome-devtools session.
+31 -6
View File
@@ -1,4 +1,10 @@
{
"NAME": "Name",
"NONE": "None",
"ORIGIN": "Origin",
"PROFILE": "Profile",
"THEME": "Concept",
"TOTEM": "Totem",
"SETTINGS": {
"world": {
"game_mode": {
@@ -197,13 +203,19 @@
"effort": "Effort",
"equip": "Equip",
"unequip": "Unequip",
"group": "Group",
"abilities": "Characteristics",
"ability": "Characteristic",
"skills": "Skills",
"skill": "Skill",
"skills_title": "Skills",
"skill_title": "Skill",
"skill_mastery": "Mastery",
"specificLevel": "Specific level",
"competence": "Skill",
"modes": "Game modes",
"traits_selector": "Trait selector",
"bonus": "Bonus",
"bonus_supplementary": "Supplementary bonus",
"penalty": "Penalty",
"reroll": "Reroll",
"equipment": "Equipment",
@@ -212,6 +224,9 @@
"techniques": "Techniques",
"difficulty": "Difficulty",
"help": "Help",
"add": "Add",
"delete": "Delete",
"learning": "Learning",
"tooling": "Equipment",
"quantity": "Quantity",
"qty": "Qty",
@@ -333,6 +348,9 @@
"CharacterNamePlaceholder": "Character Name",
"ProfilePlaceholder": "Profile",
"none": "None",
"all_characters": "All characters",
"no_group": "No group",
"include_in_phase": "Include in this phase",
"bonuses": "Bonuses",
"choose_ability": "Choose Ability",
"choose_skill": "Choose Skill",
@@ -359,7 +377,6 @@
"pool_specialty": "Specialty",
"pool_help": "Help",
"pool_tooling": "Equipment",
"pool_group": "Group",
"pool_selfControl": "Self-Control",
"pool_pools": "Pools",
"pool_total": "Total",
@@ -469,7 +486,7 @@
"mode_survie": "Survival",
"mode_cauchemar": "Nightmare",
"mode_apocalypse": "Apocalypse",
"evolution_buy_hint": "The purchase consumes and erases all accumulated Evolution dice. Limits: Survival 5D, Nightmare 6D, Apocalypse 7D total. Level 3/4 Mutations are forbidden in Survival; only one level 4 Mutation.",
"evolution_buy_hint": "The purchase consumes a number of Evolution dice equal to the chosen level (the remainder is kept). Limits: Survival 5D, Nightmare 6D, Apocalypse 7D total. Level 3/4 Mutations are forbidden in Survival; only one level 4 Mutation.",
"buy_evolution_done": "Adaptation/Mutation acquired. Evolution dice erased.",
"error_evolution_no_name": "Provide a name for the Adaptation/Mutation.",
"error_evolution_invalid_level": "Invalid level (1 to 4).",
@@ -514,6 +531,8 @@
"characteristic_hint": "Difficulty 9 + Handicap = current value. Max 3 (except Superhuman).",
"reserve_hint": "Difficulty = current value + age Handicap. Max 10.",
"evolution_hint": "Difficulty by age + Handicap = accumulated Evolution dice. Adapted totem +1D, Human -1D.",
"socle_competences_hint": "Socle de compétences atout: +{level} free Experience die (skill already possessed).",
"socle_competences_message": "Socle de compétences: +{level} free Experience die. Reminder: a Group companion who learns or develops with him a skill he possesses also gains 1 Experience die.",
"totem_ability_hint": "Prerequisites: same Totem as the character, Group level ≥ Capacity level, game mode compatible (Survival N1, Nightmare N2, Apocalypse N3).",
"background_hint": "Difficulty 9. Handicap (I) if the Background is marked with an asterisk (*). GM authorization required.",
"reputation_hint": "Reduce = more famous (Difficulty 10 target score, 1 pt max per phase). Increase = fade into obscurity (requires a major Objective, Difficulty = current Reputation, +1 per Success).",
@@ -593,8 +612,9 @@
"prohibits": "Prohibits",
"prohibits_placeholder": "eg: Fire, Water",
"rarity_0": "Common",
"rarity_1": "Rare",
"rarity_2": "Very Rare",
"rarity_1": "Uncommon",
"rarity_2": "Rare",
"rarity_3": "Very rare",
"reserves": "Reserves",
"ritual": "Ritual",
"sexes": {
@@ -1050,9 +1070,14 @@
"female": "Feminine"
},
"COMBAT": {
"Begin": "Start Combat",
"Delete": "Delete",
"Encounter": "Encounter",
"End": "End Combat",
"None": "None",
"Round": "Round"
"NotStarted": "Not Started",
"Round": "Round",
"TurnEnd": "End Turn"
},
"SIZE_LEVELS": {
"tiny": "Insect",
+11 -3
View File
@@ -248,7 +248,6 @@
"pool_specialty": "Spécialité",
"pool_help": "Entraide",
"pool_tooling": "Équipement",
"pool_group": "Groupe",
"pool_selfControl": "Sang-Froid",
"pool_pools": "Réserves",
"pool_total": "Total",
@@ -274,7 +273,6 @@
"ConfrontationHint": "Résolvez la confrontation en sélectionnant des dés",
"PurposeTrait": "Trait de but",
"SpleenTrait": "Trait de rate",
"group": "Groupe",
"abilities": "Caractéristiques",
"ability": "Caractéristique",
"skills": "Compétences",
@@ -283,6 +281,7 @@
"skill_title": "Compétence",
"skill_mastery": "Maîtrise",
"bonus": "Bonus",
"bonus_supplementary": "Bonus supplémentaire",
"penalty": "Malus",
"reroll": "Relance",
"equipment": "Equipement",
@@ -293,6 +292,9 @@
"techniques": "Techniques",
"difficulty": "Difficulté",
"help": "Entraide",
"add": "Ajouter",
"delete": "Supprimer",
"learning": "Apprentissage",
"tooling": "Equipement",
"quantity": "Quantité",
"qty": "Qté",
@@ -365,6 +367,9 @@
"choose_ability": "Choisissez une caractéristique",
"choose_skill": "Choisissez une compétence",
"none": "Aucun",
"all_characters": "Tous les personnages",
"no_group": "Sans groupe",
"include_in_phase": "Inclure dans cette phase",
"total": "Total",
"bonuses": "Bonus",
"handicap": "Handicap",
@@ -452,7 +457,7 @@
"mode_survie": "Survie",
"mode_cauchemar": "Cauchemar",
"mode_apocalypse": "Apocalypse",
"evolution_buy_hint": "L'achat consomme et efface tous les Dés d'Évolution accumulés. Limites : Survie 5D, Cauchemar 6D, Apocalypse 7D au total. Mutations 3D/4D interdites en Mode Survie ; une seule Mutation à 4D.",
"evolution_buy_hint": "L'achat consomme un nombre de Dés d'Évolution égal au niveau choisi (le reste est conservé). Limites : Survie 5D, Cauchemar 6D, Apocalypse 7D au total. Mutations 3D/4D interdites en Mode Survie ; une seule Mutation à 4D.",
"buy_evolution_done": "Adaptation/Mutation acquise. Dés d'Évolution effacés.",
"error_evolution_no_name": "Indiquez un nom pour l'Adaptation/Mutation.",
"error_evolution_invalid_level": "Niveau invalide (1 à 4).",
@@ -497,6 +502,8 @@
"characteristic_hint": "Difficulté 9 + Handicap = valeur actuelle. Max 3 (Surhumain excepté).",
"reserve_hint": "Difficulté = valeur actuelle + Handicap d'âge. Max 10.",
"evolution_hint": "Difficulté selon l'âge + Handicap = Dés d'Évolution accumulés. Totem Adapté +1D, Humain -1D.",
"socle_competences_hint": "Atout Socle de compétences : +{level}D d'Expérience gratuit (compétence déjà possédée).",
"socle_competences_message": "Socle de compétences : +{level}D d'Expérience gratuit. Rappel : un camarade du Groupe qui apprend ou développe avec lui une Compétence qu'il possède gagne aussi 1D d'Expérience.",
"totem_ability_hint": "Prérequis : même Totem que le personnage, Niveau de Groupe ≥ Niveau de la Capacité, Mode de jeu compatible (Survie N1, Cauchemar N2, Apocalypse N3).",
"background_hint": "Difficulté 9. Handicap (I) si l'Historique est marqué d'un astérisque (*). Autorisation du MJ requise.",
"reputation_hint": "Réduire = plus célèbre (Difficulté 10 score visé, 1 pt max/phase). Augmenter = se faire oublier (exige un Objectif majeur, Difficulté = Réputation actuelle, +1 par Réussite).",
@@ -1068,6 +1075,7 @@
},
"COMBAT": {
"Begin": "Commencer",
"Delete": "Supprimer",
"Encounter": "Rencontre",
"End": "Terminer",
"None": "Aucun",
+9
View File
@@ -627,6 +627,15 @@ ol.chat-log {
&.visible { max-height: 15rem; }
}
.reroll-from-effort {
h4.flexcol { gap: 2px; }
.effort-pool {
font-size: @font-size-12;
opacity: 0.8;
text-align: center;
}
}
// ── Dice results ────────────────────────────────────────────────
ul.roll-results {
list-style: none;
@@ -124,13 +124,13 @@ export class VermineBaseItemSheet extends HandlebarsApplicationMixin(foundry.app
}
static async #onReliabilityTest() {
const { ReliabilityDialog } = await import("../../system/dialogs/reliabilityDialog.mjs")
const { default: ReliabilityDialog } = await import("../../system/dialogs/reliabilityDialog.mjs")
const dialog = await ReliabilityDialog.create({ item: this.document, mode: "test" })
await dialog.render(true)
}
static async #onReliabilityRepair() {
const { ReliabilityDialog } = await import("../../system/dialogs/reliabilityDialog.mjs")
const { default: ReliabilityDialog } = await import("../../system/dialogs/reliabilityDialog.mjs")
const dialog = await ReliabilityDialog.create({ item: this.document, mode: "repair" })
await dialog.render(true)
}
+17 -5
View File
@@ -5,8 +5,8 @@ const { HandlebarsApplicationMixin } = foundry.applications.api;
/**
* Dialogue d'achat d'une Adaptation/Mutation avec les Dés d'Évolution
* accumulés. Vérifie le coût (niveau 1-4), la limite du Mode de jeu et les
* restrictions sur les Mutations ; en cas de succès, crée l'item et efface
* les Dés d'Évolution accumulés.
* restrictions sur les Mutations ; en cas de succès, crée l'item et consomme
* un nombre de Dés d'Évolution égal au niveau choisi.
*/
export default class BuyEvolutionDialog extends HandlebarsApplicationMixin(foundry.applications.api.ApplicationV2) {
@@ -61,7 +61,7 @@ export default class BuyEvolutionDialog extends HandlebarsApplicationMixin(found
};
const levels = [];
for (let value = 1; value <= 4; value++) {
levels.push({ value, cost: value });
levels.push({ value });
}
return {
evolutionDice: actor.system?.experience?.evolution || 0,
@@ -73,6 +73,18 @@ export default class BuyEvolutionDialog extends HandlebarsApplicationMixin(found
};
}
async _onRender() {
this.element.querySelectorAll("[data-roll]").forEach(inp => inp.addEventListener("change", () => this.#updateUI()));
this.#updateUI();
}
/** Met à jour le total des niveaux « après achat » selon le niveau sélectionné. */
#updateUI() {
const cost = parseInt(this.element.querySelector("#evolution-level")?.value, 10) || 0;
const el = this.element.querySelector("#evolution-total-value");
if (el) el.textContent = `${VermineExperience.evolutionTotalLevels(this.#actor) + cost}D`;
}
static async #onCancel(event, target) {
this.close();
}
@@ -102,12 +114,12 @@ export default class BuyEvolutionDialog extends HandlebarsApplicationMixin(found
mutation
}
}]);
// N'efface les Dés d'Évolution que si l'item a bien été créé.
// Ne consomme que le coût du niveau choisi (Règles p. 116-117 : coût = niveau).
if (!created || created.length === 0) {
ui.notifications.warn(game.i18n.localize("VERMINE.error_evolution_create"));
return;
}
await actor.update({ "system.experience.evolution": 0 });
await actor.update({ "system.experience.evolution": Math.max(0, (actor.system?.experience?.evolution || 0) - cost) });
ui.notifications.info(game.i18n.localize("VERMINE.buy_evolution_done"));
this.close();
+18 -6
View File
@@ -119,9 +119,10 @@ export default class CombatDialog extends HandlebarsApplicationMixin(foundry.app
const creatureLocked = !isCreature && creatureTargets.length && creatureTargets.length === targets.length;
if (creatureLocked) {
const ref = creatureTargets[0];
const creature = ref.id ? game.actors.get(ref.id) : null;
const actor = creature ?? (ref.uuid ? await fromUuid(ref.uuid) : null);
const d = parseInt(actor?.system?.combatStatus?.difficulty, 10) || 7;
// uuid d'abord : un token non lié ne doit pas résoudre vers l'acteur
// monde dont il est issu (même principe que VermineExchange).
const creature = await VermineExchange.resolveDefender({ id: ref.id, uuid: ref.uuid });
const d = parseInt(creature?.system?.combatStatus?.difficulty, 10) || 7;
lockedDifficulty = d;
difficultyOptions = [{ difficulty: d, label: "", locked: true }];
defaultDifficulty = d;
@@ -153,7 +154,8 @@ export default class CombatDialog extends HandlebarsApplicationMixin(foundry.app
lockedDifficulty,
defaultDifficulty,
hasAmmo: this.mode === "ranged" && (this.weapon?.system?.ammo ?? 0) > 0,
availableSpecialties: actor.items.filter(i => i.type === "specialty"),
// Specialties are only usable when they match the attack skill.
availableSpecialties: actor.items.filter(i => i.type === "specialty" && i.system?.skill === this.skillKey),
availableItems: actor.items.filter(i => i.type === "item"),
targets,
lourdMalus: VermineTraits.lourdMalus(actor),
@@ -266,8 +268,10 @@ export default class CombatDialog extends HandlebarsApplicationMixin(foundry.app
}
getMaxEffort() {
if (this.actor.type === "character") return this.actor.system?.abilities?.[this.abilityKey]?.value || 0;
return 0;
if (this.actor.type !== "character") return 0;
const ability = this.actor.system?.abilities?.[this.abilityKey]?.value || 0;
const effort = this.actor.system?.attributes?.effort?.value || 0;
return Math.min(ability, effort);
}
getPoolBreakdown() {
@@ -330,6 +334,14 @@ export default class CombatDialog extends HandlebarsApplicationMixin(foundry.app
if (handSel && handEl) {
handEl.textContent = handSel.options[handSel.selectedIndex]?.text ?? "";
}
const specChecked = this.#el.querySelector("input[name='usingSpecialization']:checked");
const specEl = this.#el.querySelector(".current-specialty");
if (specEl && specChecked) {
specEl.textContent = specChecked.value === "aucune"
? game.i18n.localize("VERMINE.none")
: specChecked.value;
}
}
#onInputChange() {
+29 -13
View File
@@ -48,13 +48,18 @@ export default class ExperiencePhaseDialog extends HandlebarsApplicationMixin(fo
if (!memberOf.has(id)) memberOf.set(id, g.id);
}
}
const groupById = new Map(groups.map(g => [g.id, g]));
return {
characters: characters.map(a => ({
id: a.id,
name: a.name,
dice: a.system?.experience?.dice || 0,
groupId: memberOf.get(a.id) || ""
})),
characters: characters.map(a => {
const groupId = memberOf.get(a.id) || "";
return {
id: a.id,
name: a.name,
dice: a.system?.experience?.dice || 0,
groupId,
groupName: groupId ? groupById.get(groupId)?.name : ""
};
}),
groups: groups.map(a => ({
id: a.id,
name: a.name,
@@ -72,6 +77,9 @@ export default class ExperiencePhaseDialog extends HandlebarsApplicationMixin(fo
async _onRender(context, options) {
this.element.querySelectorAll("[data-roll]").forEach(inp => inp.addEventListener("change", () => this.#updateUI()));
this.element.querySelector("#group-select")?.addEventListener("change", () => this.#updateGroupFilter());
this.element.querySelectorAll("[name^='include-']").forEach(cb =>
cb.addEventListener("click", event => event.stopPropagation())
);
this.#updateUI();
this.#updateGroupFilter();
}
@@ -84,11 +92,16 @@ export default class ExperiencePhaseDialog extends HandlebarsApplicationMixin(fo
if (el) el.textContent = `${danger + discoveries + duration}D`;
}
/** Filtre les personnages selon le groupe sélectionné (vide = tous). */
/** Filtre les personnages selon le groupe sélectionné (vide = tous, "none" = sans groupe). */
#updateGroupFilter() {
const groupId = this.element.querySelector("#group-select")?.value || "";
for (const row of this.element.querySelectorAll("[data-character-id]")) {
const show = !groupId || row.dataset.groupId === groupId;
const rowGroupId = row.dataset.groupId || "";
const show = groupId === ""
? true
: groupId === "none"
? rowGroupId === ""
: rowGroupId === groupId;
row.hidden = !show;
}
}
@@ -102,8 +115,8 @@ export default class ExperiencePhaseDialog extends HandlebarsApplicationMixin(fo
+ (parseInt(this.element.querySelector("#collective-discoveries")?.value, 10) || 0)
+ (parseInt(this.element.querySelector("#collective-duration")?.value, 10) || 0);
const groupId = this.element.querySelector("#group-select")?.value || null;
if (groupId) {
const groupId = this.element.querySelector("#group-select")?.value || "";
if (groupId && groupId !== "none") {
const group = game.actors.get(groupId);
if (group && collective > 0) {
const current = group.system?.experience?.dice || 0;
@@ -121,8 +134,11 @@ export default class ExperiencePhaseDialog extends HandlebarsApplicationMixin(fo
const results = [];
for (const row of this.element.querySelectorAll("[data-character-id]")) {
// Quand un groupe précis est choisi, ne récompenser que ses membres.
if (groupId && row.dataset.groupId !== groupId) continue;
// Filtre par groupe sélectionné (vide = tous, "none" = sans groupe)
const rowGroupId = row.dataset.groupId || "";
if (groupId === "none" ? rowGroupId !== "" : groupId !== "" && rowGroupId !== groupId) continue;
// Case « Inclure » décochée → personnage exclu de cette phase
if (!row.querySelector(`[name="include-${row.dataset.characterId}"]`)?.checked) continue;
const id = row.dataset.characterId;
const actor = game.actors.get(id);
if (!actor) continue;
@@ -164,7 +180,7 @@ export default class ExperiencePhaseDialog extends HandlebarsApplicationMixin(fo
}
const lines = [`${game.i18n.localize("VERMINE.experience_phase")} +1`];
if (groupId) {
if (groupId && groupId !== "none") {
const group = game.actors.get(groupId);
lines.push(`${group?.name ?? ""} (${game.i18n.localize("VERMINE.collective")}): +${collective}D`);
}
+35 -1
View File
@@ -145,6 +145,20 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a
return { total: items.length, byLevel };
}
/**
* Bonus de l'atout "Socle de compétences" (règles p. 112).
* Rend le niveau (1-3) de l'atout possédé par le personnage, ou 0.
* @returns {number}
*/
#socleCompetencesBonus() {
const item = (this.#actor.itemTypes?.ability || []).find(i =>
i.system?.type === "character"
&& (i.name || "").trim().toLowerCase() === "socle de compétences".toLowerCase()
);
if (!item) return 0;
return Math.min(3, Math.max(1, parseInt(item.system?.level?.value, 10) || 1));
}
async _onRender() {
this.element.dataset.actorId = this.#actor.id;
for (const inp of this.element.querySelectorAll("[data-roll]")) {
@@ -201,13 +215,16 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a
label = game.i18n.localize(VermineExperience.skillLevelLabelKey(targetLevel));
const diff = VermineExperience.skillLearningDifficulty(targetLevel);
const domain = key ? VermineExperience.isPreferredDomain(actor, key) : false;
// Atout "Socle de compétences" : 1D gratuit si la Compétence est déjà possédée.
const socleBonus = current >= 1 ? this.#socleCompetencesBonus() : 0;
return {
type,
key,
targetLevel,
difficulty: domain ? Math.max(3, diff - 2) : diff,
handicap: VermineExperience.skillLearningHandicap(targetLevel, rarity),
poolModifier: 0,
poolModifier: socleBonus,
socleBonus,
label,
targetLabel: key ? game.i18n.localize("SKILLS." + key + ".name") : ""
};
@@ -363,6 +380,15 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a
set("#diff-value", c.difficulty);
set("#handicap-value", c.handicap);
set("#required-value", 1 + c.handicap);
const socleHint = this.#el.querySelector("#socle-bonus-hint");
if (socleHint) {
if (type === "skill" && c.socleBonus > 0) {
socleHint.style.display = "";
socleHint.textContent = game.i18n.format("VERMINE.socle_competences_hint", { level: c.socleBonus });
} else {
socleHint.style.display = "none";
}
}
const pool = Math.max(0, this.#getDiceSpent() + c.poolModifier);
let poolText = `${pool}D${c.poolModifier ? ` (${c.poolModifier >= 0 ? "+" : ""}${c.poolModifier}D)` : ""}`;
if (c.adaptedDie) poolText += " — " + game.i18n.localize("VERMINE.adapted_die_x2");
@@ -496,6 +522,14 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a
if ((roll._total ?? 0) >= required) {
await this.#applySuccess(c, specialtyName, roll._total ?? 0);
}
// Atout "Socle de compétences" : rappeler le bonus conféré aux camarades
// du Groupe qui apprennent une Compétence possédée par le personnage.
if (c.socleBonus > 0) {
await ChatMessage.create({
content: game.i18n.format("VERMINE.socle_competences_message", { level: c.socleBonus }),
speaker: ChatMessage.getSpeaker({ actor })
});
}
// Les Dés d'Expérience sont perdus que le jet soit réussi ou raté.
await actor.update({ "system.experience.dice": Math.max(0, (actor.system.experience.dice || 0) - spent) });
this.close();
+3 -1
View File
@@ -288,7 +288,9 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
getMaxEffort() {
const sel = this.#getAbility();
return parseInt(sel?.value, 10) || 0;
const ability = parseInt(sel?.value, 10) || 0;
const effort = this.#actor?.system?.attributes?.effort?.value || 0;
return Math.min(ability, effort);
}
getTotems() {
+52 -23
View File
@@ -258,17 +258,19 @@ export class VermineExchange {
* Résout l'échange pour une cible donnée (défense la plus récente liée
* à cette cible, ou abstention si aucune défense).
* @param {ChatMessage} attackMessage
* @param {string|null} targetId id d'acteur de la cible (défaut : première cible)
* @param {Object|string|null} targetRef cible de l'attaque (ou son uuid),
* défaut : première cible — le matching se fait par uuid d'abord car des
* PNJ mineurs (tokens non liés) issus de la même fiche partagent le même id
* @returns {Promise<Object|null>} résultat de la résolution
*/
static async resolve(attackMessage, targetId = null) {
static async resolve(attackMessage, targetRef = null) {
const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE)
if (!exchange) return null
const target = (targetId && exchange.targets?.find(t => t.id === targetId)) || exchange.targets?.[0] || null
const target = this.#matchTarget(exchange.targets || [], targetRef)
if (!target) return null
const defs = this.#defenseMessagesFor(exchange.id, target.id)
const defs = this.#defenseMessagesFor(exchange.id, target)
const defenderMessage = defs[defs.length - 1]
const defense = defenderMessage ? defenderMessage.getFlag('world', FLAG_DEFENSE) : null
@@ -281,7 +283,7 @@ export class VermineExchange {
const defenderRef = defense
? { id: defense.actorId ?? null, uuid: defense.actorUuid ?? null, name: defense.actorName ?? '' }
: target
const defender = await this.#resolveDefender(defenderRef)
const defender = await this.resolveDefender(defenderRef)
const damage = (exchange.baseDamage || 0) + attackerSuccesses
const parryLevel = defense?.type === 'parade' ? (defense.parryLevel ?? 0) : null
@@ -384,7 +386,7 @@ export class VermineExchange {
const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE)
if (!exchange) return
const type = btn.dataset.type
const actor = await this.#resolveDefender({ id: btn.dataset.targetId, uuid: btn.dataset.targetUuid })
const actor = await this.resolveDefender({ id: btn.dataset.targetId, uuid: btn.dataset.targetUuid })
if (!actor) {
ui.notifications.warn(game.i18n.localize('VERMINE.error_no_actor_selected'))
return
@@ -474,7 +476,7 @@ export class VermineExchange {
const targets = exchange.targets?.length ? exchange.targets : [null]
for (const target of targets) {
const result = await this.resolve(attackMessage, target?.id ?? null)
const result = await this.resolve(attackMessage, target)
if (!result) continue
const block = document.createElement('div')
block.innerHTML = this.renderResolution(result)
@@ -491,7 +493,7 @@ export class VermineExchange {
ev.preventDefault()
ev.stopPropagation()
const btn = ev.currentTarget
const actor = await this.#resolveDefender({ id: btn.dataset.targetId, uuid: btn.dataset.targetUuid })
const actor = await this.resolveDefender({ id: btn.dataset.targetId, uuid: btn.dataset.targetUuid })
if (!actor) {
ui.notifications.warn(game.i18n.localize('VERMINE.error_no_actor_selected'))
return
@@ -541,9 +543,9 @@ export class VermineExchange {
}
let applied = 0
for (const target of exchange.targets) {
const r = await this.resolve(attackMessage, target?.id ?? null)
const r = await this.resolve(attackMessage, target)
if (!r?.hit || !r.woundCategory) continue
const defender = await this.#resolveDefender({ id: r.defenderId, uuid: r.defenderUuid })
const defender = await this.resolveDefender({ id: r.defenderId, uuid: r.defenderUuid })
if (!defender || !this.canApplyWound(defender)) continue
const result = await this.applyWound(defender, r.woundCategory)
if (result && result !== false) applied += 1
@@ -559,7 +561,7 @@ export class VermineExchange {
static async #onApply(ev, attackMessage) {
ev.preventDefault()
const btn = ev.currentTarget
const defender = await this.#resolveDefender({ id: btn.dataset.defenderId, uuid: btn.dataset.defenderUuid })
const defender = await this.resolveDefender({ id: btn.dataset.defenderId, uuid: btn.dataset.defenderUuid })
const wound = btn.dataset.wound
if (!defender || !wound) return
if (!this.canApplyWound(defender)) {
@@ -588,31 +590,58 @@ export class VermineExchange {
// ── Helpers internes ────────────────────────────────────────────────
/**
* Résout un acteur depuis son id d'acteur monde (acteurs liés) ou son
* uuid (tokens non liés via fromUuid).
* Résout l'acteur d'une cible depuis sa référence (id d'acteur monde ou
* uuid). Le uuid est privilégié : pour un token non lié, `token.actor.id`
* expose encore l'id de l'acteur monde dont le token est issu, et résoudre
* par id blesserait alors tous les tokens liés à cette fiche (PNJ mineurs).
* Le uuid d'un acteur synthétique ("Scene.x.Token.y.Actor.z") n'est pas
* résolvable de façon fiable par fromUuid (document non indexé quand la
* résolution est asynchrone) : on extrait donc directement le token.
* @param {{id: string|null, uuid: string|null}} ref
* @returns {Promise<Actor|null>}
*/
static async #resolveDefender({ id = null, uuid = null }) {
static async resolveDefender({ id = null, uuid = null }) {
if (uuid) {
const parts = uuid.split('.')
if (parts[0] === 'Scene' && parts[2] === 'Token') {
const token = game.scenes.get(parts[1])?.tokens.get(parts[3])
return token?.actor ?? null
}
if (parts[0] === 'Actor') {
return game.actors.get(parts[1]) ?? null
}
}
if (id) {
const actor = game.actors.get(id)
if (actor) return actor
}
if (uuid) {
const doc = await fromUuid(uuid)
if (!doc) return null
if (doc.documentType === 'Actor') return doc
if (doc.documentType === 'Token') return doc.actor ?? null
}
return null
}
static #defenseMessagesFor(exchangeId, targetId = null) {
/**
* Retrouve la cible d'un échange par uuid d'abord, puis par id. Le uuid est
* fiable pour distinguer des PNJ mineurs (tokens non liés) issus de la même
* fiche, qui partagent le même id d'acteur monde.
* @param {Object[]} targets cibles du flag d'échange
* @param {Object|string|null} ref référence de la cible (ou son uuid)
* @returns {Object|null}
*/
static #matchTarget(targets, ref) {
if (!ref) return targets[0] ?? null
const uuid = typeof ref === 'string' ? ref : (ref.uuid ?? null)
const id = typeof ref === 'string' ? null : (ref.id ?? null)
if (uuid) return targets.find(t => t.uuid === uuid) ?? null
if (id) return targets.find(t => t.id === id) ?? null
return targets[0] ?? null
}
static #defenseMessagesFor(exchangeId, target = null) {
return game.messages.contents.filter(m => {
const f = m.getFlag('world', FLAG_DEFENSE)
if (!f || f.exchangeId !== exchangeId) return false
if (targetId) return f.actorId === targetId
return true
if (!target) return true
if (target.uuid && f.actorUuid === target.uuid) return true
return f.actorId === target.id
})
}
+3 -3
View File
@@ -67,9 +67,9 @@ export const registerHooks = function () {
}
})
Hooks.once("ready", async () => {
// System initialized
//await registerTours();
// Relays player rerolls to the GM client (Foundry v14 ChatMessage
// updates are GM-only).
VermineUtils.registerRerollSocket();
});
// changement de la pause
+100 -17
View File
@@ -204,9 +204,10 @@ export class VermineUtils {
* @returns {Promise<boolean>} Whether the reroll was successful
*/
static async onReroll(message, target) {
// Verify user permissions
const msgUserId = message.user?.id ?? message.user;
if (msgUserId !== game.user?.id && !game.user?.isGM) {
// Verify user permissions. Foundry v14 renamed the ChatMessage field
// `user` to `author`, so `message.user` is undefined on this core.
const msgAuthorId = message.author?.id ?? message.user?.id ?? message.user;
if (msgAuthorId !== game.user?.id && !game.user?.isGM) {
ui.notifications.warn(game.i18n.localize('VERMINE.error_cannot_reroll'));
return false;
}
@@ -231,9 +232,6 @@ export class VermineUtils {
target.classList.add('rerolled');
// Set reroll flag
await message.setFlag("world", "reroll", true);
// Get difficulty and dice type
const ulElement = target.closest('ul');
const difficulty = ulElement?.dataset.difficulty ?? 7;
@@ -331,14 +329,55 @@ export class VermineUtils {
+ mk('adapted', stats.adapted.count, stats.adapted.success);
}
await message.update({
content: messageContent.outerHTML
});
// Persist the reroll. The GM writes the message directly ; players
// cannot update ChatMessages on Foundry v14 (permission "OWNER"), so
// their reroll is relayed through the system socket and applied by the
// GM client (see registerRerollSocket).
if (game.user?.isGM) {
await message.setFlag("world", "reroll", true);
await message.update({
content: messageContent.outerHTML
});
} else {
game.socket?.emit(`system.${game.system.id}`, {
type: "reroll",
messageId: message.id,
requestId: foundry.utils.randomID(8),
content: messageContent.outerHTML
});
}
}
return true;
}
/**
* Registers the system socket listener which applies rerolls requested by
* players. Foundry v14 only allows GMs to update ChatMessages, so player
* rerolls are relayed to the GM client through `system.<id>` (see onReroll).
* Call once after the world is ready; re-registration replaces the handler.
*/
static registerRerollSocket() {
const eventName = `system.${game.system.id}`;
const handler = async (payload, userId) => {
if (!game.user?.isGM) return;
if (payload?.type !== "reroll") return;
const message = game.messages.get(payload.messageId);
if (!message) return;
// Security: only the message author may request the reroll update.
if (message.author?.id !== userId) return;
await message.setFlag("world", "reroll", true);
await message.update({ content: payload.content });
};
if (VermineUtils.#rerollSocketHandler) {
game.socket?.off(eventName, VermineUtils.#rerollSocketHandler);
}
VermineUtils.#rerollSocketHandler = handler;
game.socket?.on(eventName, handler);
}
static #rerollSocketHandler;
/**
* Sets up event listeners for chat messages.
* @param {HTMLElement} html - The HTML element containing chat events.
@@ -376,34 +415,78 @@ export class VermineUtils {
});
}
// Add click event for granting rerolls
// Add click event for spending Effort points as rerolls (règles p.35) :
// 1 point d'Effort = 1 relance, ajouté aux relances de Compétence restantes.
html.querySelectorAll("button.grant-reroll").forEach(el => {
el.addEventListener('click', async (ev) => {
const btn = ev.currentTarget;
const rollMessage = btn.closest(".vermine-roll-message");
const grantedRerollElement = html.querySelector('#granted-reroll');
const allowedRerollElement = html.querySelector("#allowed_reroll");
const requested = parseInt(grantedRerollElement?.innerText, 10) || 0;
if (grantedRerollElement && allowedRerollElement) {
allowedRerollElement.innerText = grantedRerollElement.innerText;
// Déduit les points d'Effort de l'acteur (max : ce qu'il lui reste).
const actor = await VermineUtils.#resolveMessageActor(rollMessage);
const effort = actor?.system?.attributes?.effort;
let spent = 0;
if (effort && requested > 0) {
spent = Math.min(requested, parseInt(effort.value, 10) || 0);
if (spent > 0) {
const remaining = (parseInt(effort.value, 10) || 0) - spent;
await actor.update({ 'system.attributes.effort.value': remaining });
const poolEl = rollMessage?.querySelector('.effort-pool');
if (poolEl) poolEl.innerText = `${remaining}/${effort.max || 0}`;
}
}
// Les relances d'Effort s'ajoutent aux relances de Compétence restantes.
if (allowedRerollElement) {
const current = parseInt(allowedRerollElement.innerText, 10) || 0;
allowedRerollElement.innerText = current + spent;
}
const effortSection = btn.closest('.reroll-from-effort');
if (effortSection) effortSection.style.display = "none";
const mesEl = btn.closest('[data-message-id]');
const messageId = mesEl?.dataset?.messageId;
if (messageId) {
btn.closest('.reroll-from-effort').style.display = "none";
const rollMessage = btn.closest(".vermine-roll-message");
if (rollMessage) {
const content = rollMessage.outerHTML;
const content = rollMessage.outerHTML;
// GM applies the update directly ; players relay through the
// system socket (Foundry v14 ChatMessage updates are GM-only).
if (game.user?.isGM) {
const message = await game.messages.get(messageId);
await message.update({ content: content });
} else {
game.socket?.emit(`system.${game.system.id}`, {
type: "reroll",
messageId: messageId,
requestId: foundry.utils.randomID(8),
content: content
});
}
}
});
});
}
/**
* Résout l'acteur à l'origine d'un message de jet depuis les attributs
* data-actor-id / data-actor-uuid posés sur le message. Le uuid est
* privilégié : il distingue les tokens non liés (PNJ mineurs) issus de la
* même fiche, qui partagent le même id d'acteur monde.
* @param {HTMLElement} rollMessage
* @returns {Promise<Actor|null>}
*/
static async #resolveMessageActor(rollMessage) {
if (!rollMessage) return null;
const id = rollMessage.dataset.actorId ?? null;
const uuid = rollMessage.dataset.actorUuid ?? null;
if (!id && !uuid) return null;
const { VermineExchange } = await import('./exchange.mjs');
return VermineExchange.resolveDefender({ id, uuid });
}
/**
* Displays dice rolls in 3D if available.
* @param {Roll} roll - The roll to display
+1
View File
@@ -168,6 +168,7 @@
"distance": 1,
"units": "m"
},
"socket": true,
"primaryTokenAttribute": "health",
"secondaryTokenAttribute": "power",
"url": "https://www.uberwald.me/gitea/uberwald/vermine2047",
+1 -1
View File
@@ -15,7 +15,7 @@
<label class="label" for="evolution-level">{{ localize 'VERMINE.evolution_level' }}</label>
<select class="info-value" data-roll="true" id="evolution-level" name="evolution-level">
{{#each levels}}
<option value="{{ this.value }}">{{ this.value }}D{{ this.cost }}D</option>
<option value="{{ this.value }}">{{ this.value }}D</option>
{{/each}}
</select>
</div>
+37 -1
View File
@@ -66,6 +66,42 @@
</div>
</div>
<!-- SPECIALTIES (limited to the attack skill) -->
{{#if availableSpecialties.length}}
<details class="specialties-section">
<summary class="flexrow">
<span class="label">{{localize 'VERMINE.specialty'}} (+1D)</span>
<span class="current-specialty">{{localize 'VERMINE.none'}}</span>
</summary>
<div class="flexrow specialty-options">
<label class="flexrow">
<input
type="radio"
data-roll="true"
name="usingSpecialization"
id="usingSpecialization"
value="aucune"
checked
/>
<span>{{localize 'VERMINE.none'}}</span>
</label>
{{#each availableSpecialties as |spec ind|}}
<label class="flexrow">
<input
type="radio"
data-roll="true"
name="usingSpecialization"
id="usingSpecialization"
value="{{spec.name}}"
data-spec-skill="{{spec.system.skill}}"
/>
<span>{{spec.name}}</span>
</label>
{{/each}}
</div>
</details>
{{/if}}
<!-- DIFFICULTY -->
<details class="difficulty-section" open>
<summary class="flexrow">
@@ -165,7 +201,7 @@
</div>
<div class="flexrow bonus-item">
<label class="label" for="group">{{localize 'VERMINE.group'}}</label>
<label class="label" for="group">{{localize 'VERMINE.bonus_supplementary'}}</label>
<input type="number" data-roll="true" class="numeric-entry" style="text-align: center;"
name="group" id="group" min="0" max="5" value="0" />
<span>D</span>
@@ -36,7 +36,8 @@
<div class="flexrow bonus-item">
<label class="label" for="group-select">{{ localize 'VERMINE.collective_group' }}</label>
<select class="info-value" id="group-select" name="group-select">
<option value="">{{ localize 'VERMINE.none' }}</option>
<option value="">{{ localize 'VERMINE.all_characters' }}</option>
<option value="none">{{ localize 'VERMINE.no_group' }}</option>
{{#each groups}}
<option value="{{ this.id }}">{{ this.name }} ({{ this.dice }}D)</option>
{{/each}}
@@ -49,8 +50,14 @@
{{#each characters}}
<details class="difficulty-section" data-character-id="{{ this.id }}" data-group-id="{{ this.groupId }}" open>
<summary class="flexrow">
<label class="flexrow exp-include-label" title="{{ localize 'VERMINE.include_in_phase' }}">
<input type="checkbox" data-roll="true" id="include-{{ this.id }}" name="include-{{ this.id }}" checked />
</label>
<span class="label">{{ this.name }}</span>
<span class="current-values">{{ localize 'VERMINE.experience_dice' }}: {{ this.dice }}D</span>
<span class="current-values">
{{#if this.groupName}}[{{ this.groupName }}]{{ else }}[{{ localize 'VERMINE.no_group' }}]{{/if}}
{{ localize 'VERMINE.experience_dice' }}: {{ this.dice }}D
</span>
</summary>
<div class="flexrow difficulty-controls">
<div class="flexcol">
+1
View File
@@ -26,6 +26,7 @@
</option>
{{/each}}
</select>
<p class="learning-hint" id="socle-bonus-hint" style="display:none;"></p>
</div>
<div id="learning-specialty-section" class="learning-targets" style="display:none;">
+1 -1
View File
@@ -73,7 +73,7 @@
</div>
<div class="flexrow bonus-item">
<label class="label" for="group">{{localize 'VERMINE.group'}}</label>
<label class="label" for="group">{{localize 'VERMINE.bonus_supplementary'}}</label>
<input type="number" data-roll="true" class="numeric-entry" style="text-align: center;"
name="group" id="group" min="0" max="5" value="0" />
<span>D</span>
+1 -1
View File
@@ -203,7 +203,7 @@
<!-- GROUP -->
<div class="flexrow bonus-item">
<label class="label" for="group">{{localize 'VERMINE.group'}}</label>
<label class="label" for="group">{{localize 'VERMINE.bonus_supplementary'}}</label>
<input
type="number"
data-roll="true"
+1 -1
View File
@@ -38,7 +38,7 @@
{{#if item.system.effects}}
<div class="resource flexcol">
<span class="resource-label">{{ localize 'UI.effects' }}:</span>
<span class="resource-label">{{ localize 'UI.effects.name' }}:</span>
<ul class="unstyled">
{{#each item.system.effects as |effect|}}
<li>{{effect}}</li>
+1 -1
View File
@@ -26,7 +26,7 @@
{{#if item.system.effect}}
<div class="resource flexrow">
<span class="resource-label">{{ localize 'UI.effects' }}:</span>
<span class="resource-label">{{ localize 'UI.effects.name' }}:</span>
<span>{{item.system.effect}}</span>
</div>
{{/if}}
+5 -2
View File
@@ -1,4 +1,4 @@
<div class="vermine-roll-message" data-free-successes="{{verdict.bonusSuccesses}}">
<div class="vermine-roll-message" data-free-successes="{{verdict.bonusSuccesses}}" data-actor-id="{{param.actor.id}}" data-actor-uuid="{{param.actor.uuid}}">
{{!-- Verdict --}}
<div class="roll-verdict {{#if verdict.success}}success{{else}}failure{{/if}}">
{{#if verdict.success}}
@@ -191,7 +191,7 @@
<span class="pool-item pool-malus">{{localize "VERMINE.pool_psychology"}} -{{param.poolBreakdown.psychology}}D</span>
{{/if}}
{{#if param.poolBreakdown.group}}
<span class="pool-item">{{localize "VERMINE.pool_group"}} +{{param.poolBreakdown.group}}D</span>
<span class="pool-item">{{localize "VERMINE.bonus_supplementary"}} +{{param.poolBreakdown.group}}D</span>
{{/if}}
{{#if param.poolBreakdown.pools}}
<span class="pool-item">{{localize "VERMINE.pool_pools"}} +{{param.poolBreakdown.pools}}D</span>
@@ -226,9 +226,11 @@
<div class="reroll-fromroll">
<h4>{{localize "VERMINE.rerolls_possible"}} : <span id="allowed_reroll">{{param.Reroll}}</span></h4>
<div class="reroll flexrow">
{{#ifgt param.actor.system.attributes.effort.value 0}}
<div class="reroll-from-effort flexrow">
<h4 class="flexcol">
<span>{{localize "VERMINE.effort"}}</span>
<span class="effort-pool">{{param.actor.system.attributes.effort.value}}/{{param.actor.system.attributes.effort.max}}</span>
</h4>
<input type="range"
min="0"
@@ -240,6 +242,7 @@
<span id="granted-reroll">0</span>
</button>
</div>
{{/ifgt}}
</div>
</div>