foundryvtt-reve-de-dragon/module/apps/rdd-import-stats.js

716 lines
22 KiB
JavaScript
Raw Normal View History

2024-10-30 22:45:47 +01:00
/************************************************************************************/
import "./xregexp-all.js";
import { SystemCompendiums } from "../settings/system-compendiums.js";
import { RdDBaseActorReve } from "../actor/base-actor-reve.js";
import { Grammar } from "../grammar.js";
import { Misc } from "../misc.js";
import { ENTITE_INCARNE, ENTITE_NONINCARNE } from "../constants.js";
import { RdDItemTete } from "../item/tete.js";
import { ITEM_TYPES } from "../item.js";
2024-10-30 22:45:47 +01:00
const WHITESPACES = "\\s+"
const NUMERIC = "[\\+\\-]?\\d+"
const NUMERIC_VALUE = "(?<value>" + NUMERIC + ")"
2024-10-30 22:45:47 +01:00
const XREGEXP_COMP_CREATURE = WHITESPACES + "(?<carac>\\d+)"
+ WHITESPACES + NUMERIC_VALUE
+ "(" + WHITESPACES + "(?<init>\\d+)?\\s+?(?<dommages>[\\+\\-]?\\d+)?" + ")?"
2024-10-30 22:45:47 +01:00
// Skill parser depending on the type of actor
const compParser = {
personnage: "(\\s+\\((?<special>[^\\)]+)\\))?(,\\s*\\p{Letter}+)*(\\s+(?<malus>avec armure))?" + WHITESPACES + NUMERIC_VALUE,
creature: XREGEXP_COMP_CREATURE,
entite: XREGEXP_COMP_CREATURE
}
const MANIEMENTS = {
'de lancer': (weapon) => { return { name: weapon.system.lancer, categorie: 'lancer' } },
'de jet': (weapon) => { return { name: weapon.system.lancer, categorie: 'lancer' } },
'à une main': (weapon) => { return { name: weapon.system.competence, categorie: 'melee' } },
'à deux mains': (weapon) => { return { name: weapon.system.competence.replace("à 1 main", "à 2 mains"), categorie: 'melee' } },
'mêlée': (weapon) => { return { name: weapon.system.competence, categorie: 'melee' } },
}
const XREGEXP_WEAPON_MANIEMENT = "(?<maniement>(" + Misc.join(Object.keys(MANIEMENTS), '|') + "))"
const XREGEXP_SORT_VOIE = "(?<voies>[OHNT](\\/[OHNT])*)"
const XREGEXP_SORT_NAME = "(?<name>[^\\(]+)"
2024-12-07 22:58:02 +01:00
// const XREGEXP_SORT_CASE = "(?<coord>([A-Za-zÀ-ÖØ-öø-ÿ\\s\\-]+|[A-M]\\d{1,2})+)"
const XREGEXP_SORT_CASE = "(?<coord>([A-Za-zÀ-ÖØ-öø-ÿ\\s\\-]+|[A-M]\\d{1,2}))"
const XREGEXP_SORT = "(" + XREGEXP_SORT_VOIE
+ WHITESPACES + XREGEXP_SORT_NAME
2024-12-07 22:58:02 +01:00
+ WHITESPACES + "\\(" + XREGEXP_SORT_CASE + "\\)"
+ WHITESPACES + "R(?<diff>([\\-\\d]+|(\\w|\\s)+))"
+ WHITESPACES + "r(?<reve>(\\d+(\\+)?|\\s\\w+))"
+ "(" + WHITESPACES + "\\+(?<bonus>\\d+)\\s?%" + WHITESPACES + "en" + WHITESPACES + "(?<bonuscase>[A-M]\\d{1,2})" + ")?"
+ ")"
2024-12-07 22:58:02 +01:00
const XREGEXP_SORTRESERVE_CASE = "(?<coord>[A-M]\\d{1,2})";
const XREGEXP_SORT_RESERVE = XREGEXP_SORTRESERVE_CASE
+ WHITESPACES + XREGEXP_SORT_NAME
+ WHITESPACES + "(\\((?<description>[^\\)]+)\\))?"
2024-10-30 22:45:47 +01:00
// Main class for parsing a stat block
export class RdDStatBlockParser {
2024-10-30 22:45:47 +01:00
static openInputDialog() {
let dialog = new Dialog({
title: "Import de stats de PNJ/Créatures",
content: `
<div>
<p>Coller le texte de la stat ici</p>
<textarea id="statBlock" style="width: 100%; height: 200px;"></textarea>
</div>
`,
buttons: {
ok: {
label: "OK",
callback: async (html) => {
let statBlock = html.find("#statBlock")[0].value;
await RdDStatBlockParser.parseStatBlock(statBlock);
dialog.close();
}
},
cancel: {
label: "Cancel"
}
}
});
dialog.render(true);
}
2024-10-31 23:36:16 +01:00
static fixWeirdPDF(statString) {
// Split the statString into lines
let lines = statString.split("\n");
let newLines = [];
let index = 0;
let nextType = "string";
// Loop through each line
for (let i = 0; i < lines.length; i++) {
// remove trailing spaces
lines[i] = lines[i].trim();
// Is it text ?
if (lines[i].match(/^[a-zA-Zéêè\s]+/)) {
if (nextType == "string") {
2024-10-31 23:36:16 +01:00
newLines[index] = lines[i];
nextType = "number";
2024-10-31 23:36:16 +01:00
} else {
console.log("Wrong sequence string detected...", lines[i], nextType);
}
}
// Is it a number ?
if (lines[i].match(/^[\d\s]+/)) {
if (nextType == "number") {
2024-10-31 23:36:16 +01:00
newLines[index] = newLines[index] + lines[i];
nextType = "string";
index++;
} else {
console.log("Wrong sequence number detected...", lines[i], nextType);
}
}
}
}
static getHeureKey(heure) {
for (let h of game.system.rdd.config.heuresRdD) {
if (h.label.toLowerCase() == heure.toLowerCase()) {
return h.value;
}
}
return "vaisseau";
}
static fixCompName(name) {
name = name.replace("Voie d'", "");
name = name.replace("Voie de ", "");
return name
2024-12-06 22:58:37 +01:00
}
static async parseStatBlock(statString) {
2024-10-30 22:45:47 +01:00
//statString = statBlock03;
if (!statString) {
return;
}
2024-10-31 23:36:16 +01:00
// Special function to fix strange/weird copy/paste from PDF readers
// Unused up to now : this.fixWeirdPDF(statString);
2024-10-30 22:45:47 +01:00
// Replace all endline by space in the statString
statString = statString.replace(/\n/g, " ");
// Remove all multiple spaces
statString = statString.replace(/\s{2,}/g, " ");
// Remove all leading and trailing spaces
statString = statString.trim();
// TODO: check for entite
let type = RdDStatBlockParser.parseActorType(statString);
2024-10-30 22:45:47 +01:00
// Now start carac
let actorData = foundry.utils.deepClone(game.model.Actor[type]);
let items = [];
actorData.flags = { hautRevant: false, malusArmure: 0, type }
for (let key in actorData.carac) {
let caracDef = actorData.carac[key];
2024-10-30 22:45:47 +01:00
// Parse the stat string for each caracteristic
let carac = XRegExp.exec(statString, XRegExp(caracDef.label + "\\s+(?<value>\\d+)", 'giu'));
2024-10-30 22:45:47 +01:00
if (carac?.value) {
actorData.carac[key].value = Number(carac.value);
}
}
// If creature we need to setup additionnal fields
switch (type) {
case "creature":
RdDStatBlockParser.parseCreature(statString, actorData)
break
case "entite":
RdDStatBlockParser.parseEntite(statString, actorData)
break
2024-10-30 22:45:47 +01:00
}
if (type == "personnage") {
// Now process armors
await RdDStatBlockParser.parseArmors(statString, actorData, items);
}
2024-10-30 22:45:47 +01:00
// Get skills from compendium
await RdDStatBlockParser.parseCompetences(statString, actorData, items);
if (type == "personnage") {
// Now process weapons
await RdDStatBlockParser.parseWeapons(statString, items);
await RdDStatBlockParser.parseHautReve(statString, actorData, items);
RdDStatBlockParser.parsePersonnage(statString, actorData);
}
const name = RdDStatBlockParser.extractName(type, statString);
actorData.flags = undefined
console.log(actorData);
let newActor = await RdDBaseActorReve.create({ name, type, system: actorData, items });
await newActor.remiseANeuf()
await RdDStatBlockParser.adjustAttacks(newActor)
await RdDStatBlockParser.setValeursActuelles(newActor, statString)
await newActor?.sheet.render(true)
}
static async parseCompetences(statString, actorData, items) {
const competences = await SystemCompendiums.getCompetences(actorData.flags.type);
2024-10-30 22:45:47 +01:00
//console.log("Competences : ", competences);
for (let competence of competences) {
let pushed = actorData.flags.type != "personnage"
let compNameToSearch = RdDStatBlockParser.fixCompName(competence.name)
XRegExp.forEach(statString, XRegExp("\\s" + compNameToSearch + compParser[actorData.flags.type], 'giu'),
function (compMatch, i) {
items.push(RdDStatBlockParser.prepareCompetence(actorData, competence, compMatch))
if (!compMatch.special) {
pushed = true
2024-10-30 22:45:47 +01:00
}
})
if (!pushed) {
// ajout niveau de base
items.push(competence.toObject())
2024-10-30 22:45:47 +01:00
}
}
}
static prepareCompetence(actorData, competence, compMatch) {
const comp = competence.toObject();
if (compMatch.special) {
comp._id = undefined
comp.name = `${comp.name} (${compMatch.special})`
}
comp.system.niveau = Number(compMatch.value);
if (compMatch.malus) {
comp.system.niveau = Number(compMatch.value) - actorData.flags.malusArmure
}
if (comp.system.categorie == 'draconic' && comp.system.niveau > -11) {
actorData.flags.hautRevant = true
}
if (["creature", "entite"].includes(actorData.flags.type)) {
comp.system.carac_value = Number(compMatch.carac);
if (compMatch.dommages != undefined) {
comp.system.dommages = Number(compMatch.dommages)
comp.system.iscombat = true
2024-10-30 22:45:47 +01:00
}
}
return comp
}
2024-10-30 22:45:47 +01:00
static async parseArmors(statString, actorData, items) {
const armors = await SystemCompendiums.getWorldOrCompendiumItems("armure", "equipement");
for (let armor of armors) {
let matchArmor = XRegExp.exec(statString, XRegExp(armor.name, 'giu'));
if (matchArmor) {
armor = armor.toObject()
armor.system.equipe = true
actorData.flags.malusArmure = armor.system.malus
items.push(armor)
break
}
}
}
static async parseWeapons(statString, items) {
const weapons = await SystemCompendiums.getWorldOrCompendiumItems("arme", "equipement");
2024-10-30 22:45:47 +01:00
//console.log("Equipement : ", equipment);
// TODO: les noms d'armes peuvent avoir un suffixe (à une main, lancée) qui détermine la compétence correspondante
// TODO: une arme peut être spécifique ("fourche"), ajouter une compétence dans ces cas là?
for (let weapon of weapons) {
let nomArmeManiement = XRegExp.exec(weapon.name, XRegExp(".*" + XREGEXP_WEAPON_MANIEMENT));
if (nomArmeManiement) {
continue // ignore les objets 'Dague de jet" ou "dague mêlée"
}
let weapMatch = XRegExp.exec(statString, XRegExp(weapon.name
+ "(\\s*" + XREGEXP_WEAPON_MANIEMENT + ")?"
+ "\\s+(?<value>[\\+\\-]?\\d+)", 'giu'));
if (weapMatch) {
weapon = weapon.toObject();
weapon.system.equipe = 'true';
items.push(weapon);
const niveau = Number(weapMatch.value);
2024-10-30 22:45:47 +01:00
// now process the skill
if (weapMatch?.maniement) {
RdDStatBlockParser.setNiveauCompetenceArme(items, MANIEMENTS[weapMatch.maniement](weapon), niveau)
2024-10-30 22:45:47 +01:00
}
else {
RdDStatBlockParser.setNiveauCompetenceArme(items, { name: weapon.system.competence, categorie: 'melee' }, niveau)
RdDStatBlockParser.setNiveauCompetenceArme(items, { name: weapon.system.tir, categorie: 'tir' }, niveau)
RdDStatBlockParser.setNiveauCompetenceArme(items, { name: weapon.system.lancer, categorie: 'lancer' }, niveau)
2024-10-30 22:45:47 +01:00
}
}
}
}
2024-10-30 22:45:47 +01:00
static setNiveauCompetenceArme(items, competence, niveau) {
if (competence != "") {
const item = items.find(i => i.system.categorie == competence.categorie && Grammar.equalsInsensitive(i.name, competence.name))
if (item) {
item.system.niveau = niveau
2024-10-30 22:45:47 +01:00
}
}
}
static async adjustAttacks(newActor) {
if (["creature", "entite"].includes(newActor.type)) {
const bonusDommages = newActor.getBonusDegat()
const ajustementAttaques = newActor.itemTypes[ITEM_TYPES.competencecreature].filter(it => it.system.iscombat)
.map(it => {
return {
_id: it.id,
'system.categorie': 'melee',
'system.dommages': it.system.dommages - bonusDommages
}
})
await newActor.updateEmbeddedDocuments('Item', ajustementAttaques)
}
}
2024-12-06 22:59:03 +01:00
static async setValeursActuelles(newActor, statString) {
const updates = {
}
const endurance = XRegExp.exec(statString, XRegExp("endurance\\s+(?<value>\\d+)\\s+(\\(actuelle\\s*:\\s+(?<actuelle>\\d+)\\))?", 'giu'));
if (endurance?.value) {
if (newActor.getEnduranceMax() != endurance.value) {
ui.notifications.warn(`Vérifier le calcul de l'endurance, calcul: ${newActor.getEnduranceMax()} / import: ${endurance.value}`)
}
}
if (endurance?.actuelle) {
updates['system.sante.endurance.value'] = Number(endurance?.actuelle)
}
const vie = XRegExp.exec(statString, XRegExp("vie\\s+(?<value>\\d+)\\s+(\\(actuelle\\s*:\\s+(?<actuelle>\\d+)\\))?", 'giu'));
if (vie?.value) {
if (newActor.getVieMax() != vie.value) {
ui.notifications.warn(`Vérifier le calcul de la vie, calcul: ${newActor.getVieMax()} / import: ${vie.value}`)
}
}
if (vie?.actuelle) {
updates['system.sante.vie.value'] = Number(vie?.actuelle)
}
await newActor.update(updates)
}
static async parseHautReve(statString, actorData, items) {
// Attemp to detect spell
let sorts = await SystemCompendiums.getWorldOrCompendiumItems("sort", "sorts-oniros");
sorts = sorts.concat(await SystemCompendiums.getWorldOrCompendiumItems("sort", "sorts-hypnos"));
sorts = sorts.concat(await SystemCompendiums.getWorldOrCompendiumItems("sort", "sorts-narcos"));
sorts = sorts.concat(await SystemCompendiums.getWorldOrCompendiumItems("sort", "sorts-thanatos"));
XRegExp.forEach(statString, XRegExp(XREGEXP_SORT, 'gu' /* keep case sensitive to match the spell draconic skill */),
function (matchSort, i) {
2024-12-07 22:58:02 +01:00
actorData.flags.hautRevant = true
const sortName = Grammar.toLowerCaseNoAccent(matchSort.name).trim().replace("", "'");
let sort = sorts.find(s => Grammar.toLowerCaseNoAccent(s.name) == sortName)
if (sort) {
sort = sort.toObject();
if (matchSort.bonus && matchSort.bonuscase) {
sort.system.bonuscase = `${matchSort.bonuscase}:${matchSort.bonus}`;
}
items.push(sort);
}
else {
ui.notifications.warn(`Impossible de trouver le sort ${matchSort.name} / ${sortName}`)
}
2024-12-07 22:58:02 +01:00
})
const sortsReserve = XRegExp.exec(statString, XRegExp('En réserve\\s+(?<reserve>.*)', 'gu' /* keep case sensitive to match the spell draconic skill */))
if (sortsReserve?.reserve) {
actorData.flags.hautRevant = true
XRegExp.forEach(sortsReserve.reserve, XRegExp(XREGEXP_SORT_RESERVE, 'giu'),
function (matchSortReserve, i) {
const name = Grammar.toLowerCaseNoAccent(matchSortReserve.name).trim().replace("", "'");
const sort = sorts.find(s => Grammar.toLowerCaseNoAccent(s.name) == name)
if (sort) {
if (!items.find(it => it._id == sort.id)) {
const nouveauSort = sort.toObject()
nouveauSort.system.bonuscase = `${matchSortReserve.coord}:1`;
items.push(sort.toObject())
}
items.push({
name: sort.name,
type: 'sortreserve',
img: sort.img,
system: {
sortid: sort.id,
draconic: sort.system.draconic,
coord: matchSortReserve.coord,
ptreve: Number(sort.system.ptreve.match(/\d+/)),
},
description: matchSortReserve.description
})
}
else {
ui.notifications.warn(`Impossible de mettre ${matchSortReserve.name} en réserve en ${matchSortReserve.coord}`)
}
})
}
if (actorData.flags.hautRevant) {
const donHR = await RdDItemTete.teteDonDeHautReve();
if (donHR) {
items.push(donHR.toObject());
}
const demiReve = XRegExp.exec(statString, XRegExp("Demi-rêve\\s+(?<value>[A-M]\\d{1,2})", 'giu'))
actorData.reve.tmrpos.coord = demiReve?.value ?? 'A1'
}
}
static parsePersonnage(statString, actorData) {
actorData.reve.seuil.value = actorData.carac.reve.value
actorData.compteurs.chance.value = actorData.carac.chance.value
const reveActuel = XRegExp.exec(statString, XRegExp("Rêve actuel\\s+(?<value>\\d+)", 'giu'))
actorData.reve.reve.value = reveActuel?.value ? Number(reveActuel.value) : actorData.reve.seuil.value
const feminin = XRegExp.exec(statString, XRegExp("né(?<value>e?) à", 'giu'));
actorData.sexe = (feminin?.value == 'e') ? 'féminin' : 'masculin';
// Get hour name : heure du XXXXX
const heure = XRegExp.exec(statString, XRegExp("heure (du|de la|des|de l\')\\s*(?<value>[A-Za-zÀ-ÖØ-öø-ÿ\\s]+),", 'giu'));
actorData.heure = this.getHeureKey(heure?.value || "Vaisseau");
// Get age
const age = XRegExp.exec(statString, XRegExp("(?<value>\\d+) ans", 'giu'));
if (age?.value) {
actorData.age = Number(age.value);
}
// Get height
const taille = XRegExp.exec(statString, XRegExp("(?<value>\\d+m\\d+)", 'giu'));
if (taille?.value) {
actorData.taille = taille.value;
}
// Get weight
const poids = XRegExp.exec(statString, XRegExp(",\\s+(?<value>\\d+)\\s+kg", 'giu'));
if (poids?.value) {
actorData.poids = poids.value + ' kg';
}
// Get cheveux
const cheveux = XRegExp.exec(statString, XRegExp("kg,\\s+(?<value>[A-Za-zÀ-ÖØ-öø-ÿ\\s\\-]+),\\s+yeux", 'giu'));
if (cheveux?.value) {
actorData.cheveux = cheveux.value;
}
// Get yeux
const yeux = XRegExp.exec(statString, XRegExp("yeux\\s+(?<value>[A-Za-zÀ-ÖØ-öø-ÿ\\s\\-]+), Beau", 'giu'));
if (yeux?.value) {
actorData.yeux = yeux.value;
2024-10-30 22:45:47 +01:00
}
// Get beauty
const beaute = XRegExp.exec(statString, XRegExp("beauté\\s+(?<value>\\d+)", 'giu'));
if (beaute?.value) {
actorData.beaute = Number(beaute.value);
}
2024-10-30 22:45:47 +01:00
}
static parseCreature(statString, actorData) {
2024-12-17 01:32:56 +01:00
let protection = XRegExp.exec(statString, XRegExp("protection(\\s+naturelle)?\\s+(?<value>[\\-]?\\d+)", 'giu'));
if (protection?.value) {
actorData.attributs.protection.value = Number(protection.value);
}
let vitesse = XRegExp.exec(statString, XRegExp("vitesse\\s+(?<value>[\\d\\/]+)", 'giu'));
if (vitesse?.value) {
actorData.attributs.vitesse.value = vitesse.value;
}
}
static parseEntite(statString, actorData) {
actorData.definition.categorieentite = 'cauchemar'
actorData.definition.typeentite = ENTITE_NONINCARNE
let endurance = XRegExp.exec(statString, XRegExp("endurance\\s+(?<value>\\d+)", 'giu'));
if (endurance?.value) {
actorData.sante.endurance.value = Number(endurance.value);
actorData.sante.endurance.max = Number(endurance.value);
actorData.definition.typeentite = ENTITE_INCARNE
}
let vitesse = XRegExp.exec(statString, XRegExp("vitesse\\s+(?<value>[\\d\\/]+)", 'giu'));
if (vitesse?.value) {
actorData.attributs.vitesse.value = vitesse.value;
}
}
static parseActorType(statString) {
let force = XRegExp.exec(statString, XRegExp("Force\\s+(?<value>[\\+\\-]?\\d+)", 'giu'))
let vue = XRegExp.exec(statString, XRegExp("Vue\\s+(?<value>[\\+\\-]?\\d+)", 'giu'))
let perception = XRegExp.exec(statString, XRegExp("perception\\s+(?<value>\\d+)", 'giu'))
if (!force) {
return "entite"
}
if (!vue || perception) {
return "creature"
}
return "personnage"
}
static extractName(actorType, statString) {
if (actorType == "personnage") {
// Check if ',né le' is present
let namePersonnage = "Importé"
if (statString.includes(", né")) {
// Name is all string before first comma ','
namePersonnage = XRegExp.exec(statString, XRegExp("(?<value>[\\p{Letter}\'\\-\\s\\d]+),", 'giu'));
} else {
namePersonnage = XRegExp.exec(statString, XRegExp("(?<value>[\\p{Letter}\'\\-\\s\\d]+)\\s+TAILLE", 'giu'));
}
if (namePersonnage?.value) {
return Misc.upperFirst(namePersonnage?.value.toLowerCase());
}
}
const name = XRegExp.exec(statString, XRegExp("(?<value>.+)\\s+taille", 'giu'));
if (actorType == "entite") {
if (!(name?.value)) {
const nameEntiteReve = XRegExp.exec(statString, XRegExp("(?<value>.+)\\s+rêve", 'giu'));
return Misc.upperFirst(nameEntiteReve?.value || "Importé");
}
}
return Misc.upperFirst(name?.value || "Importé");
}
static warning(message) {
ui.notifications.warn(message);
}
2024-10-30 22:45:47 +01:00
}
/************************************************************************************/
// Some internal test strings
let statBlock01 = `+$16(/, baron de Sylvedire, né à lheure du
Roseau, 40 ans, 1m78, 65 kg, Beauté 13.
TAILLE
10
Mêlée
14
APPARENCE
13
Tir
11
CONSTITUTION
12
Lancer
11
FORCE
12
Dérobée
13
AGILITÉ
16
Vie
11
DEXTÉRITÉ
13
Endurance
25
VUE
10
+dom
0
OUÏE
11
Protection
2 ou 4
ODO-GOÛT
9
cuir souple
VOLONTÉ
14
ou cuir / métal
INTELLECT
9
EMPATHIE
11
RÊVE
13
CHANCE
10
niv
init
+dom
Épée dragonne
+5
12
+3
Hache de bataille
+6
13
+3
Bouclier moyen
+5
Dague mêlée
+4
11
+1
Corps à corps
+4
11
(0)
Esquive
+8
Escalade, Saut +4 / Commerce +3 / Équitation
+6 / Chirurgie 0 / Survie en extérieur +4 / Survie fo-
rêt +6 / Acrobatie -2 / Métallurgie +2 / Natation +3 /
Légendes -1 / Écriture -4
`;
let statBlock02 = `/HVJDUGHV
TAILLE
11
Mêlée
12
CONSTITUTION
11
Tir
11
FORCE
12
Lancer
11
AGILITÉ
12
Dérobée
11
DEXTERITÉ
11
Vie
11
VUE
11
Endurance
22
OUÏE
11
Vitesse
12
VOLONTÉ
10
+dom
0
Protection
4
cuir / métal
niv
init
+dom
Hache de bataille
+4
10
+3
Bouclier moyen
+4
Dague mêlée
+3
9
+1
Arc
+5
10
+2
Corps à corps
+3
9
(0)
Esquive avec armure
+2
Course +1/ Vigilance +4
`;
let statBlock03 = `rencontres sont laissées à /HVFKLHQVORXSVGXEDURQ
chaque gardien des rêves.
TAILLE
8
Vie
10
CONSTITUTION FORCE
12
11
Endurance
Vitesse
12/38
21
/HVFKLHQV]RPELV
PERCEPTION 13
+dom
0
VOLONTÉ
10
Protection
0
Les « monstres » apparaîtront un soir, durant
RÊVE
10
lheure du Serpent, et attaqueront les voya-
niv
init
+dom
geurs à leur campement. Si ces derniers ne
Morsure
13
+4
10
+1
campent pas, ils apparaîtront tout de même à
Esquive
11
+3
lheure du Serpent. Le feu ne les effraie pas. Ils
Course, Saut
12
+3
ne sont pas très rapides, mais en revanche, très
Discrétion
12
+3
silencieux : ils naboient pas. Les voyageurs
Vigilance
13
+3
`