642 lines
18 KiB
JavaScript
642 lines
18 KiB
JavaScript
/************************************************************************************/
|
||
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";
|
||
|
||
const WHITESPACES = "\\s+"
|
||
const NUMERIC = "[\\+\\-]?\\d+"
|
||
const NUMERIC_VALUE = "(?<value>" + NUMERIC + ")"
|
||
|
||
const XREGEXP_COMP_CREATURE = WHITESPACES + "(?<carac>\\d+)"
|
||
+ WHITESPACES + NUMERIC_VALUE
|
||
+ "(" + WHITESPACES + "(?<init>\\d+)?\\s+?(?<dommages>[\\+\\-]?\\d+)?" + ")?"
|
||
|
||
// 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 main': (weapon) => { return { name: weapon.system.competence.replace("à 1 main", "à 2 main"), 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>[^\\(]+)"
|
||
const XREGEXP_SORT_CASE = "\\((?<case>([A-Za-zÀ-ÖØ-öø-ÿ\\s\\-]+|[A-M]\\d{1,2})+)\\)";
|
||
|
||
const XREGEXP_SORT = "(" + XREGEXP_SORT_VOIE
|
||
+ WHITESPACES + XREGEXP_SORT_NAME
|
||
+ 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})" + ")?"
|
||
+ ")"
|
||
|
||
|
||
// Main class for parsing a stat block
|
||
export class RdDStatBlockParser {
|
||
|
||
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);
|
||
}
|
||
|
||
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") {
|
||
newLines[index] = lines[i];
|
||
nextType = "number";
|
||
} else {
|
||
console.log("Wrong sequence string detected...", lines[i], nextType);
|
||
}
|
||
}
|
||
// Is it a number ?
|
||
if (lines[i].match(/^[\d\s]+/)) {
|
||
if (nextType == "number") {
|
||
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
|
||
}
|
||
|
||
static async parseStatBlock(statString) {
|
||
|
||
//statString = statBlock03;
|
||
if (!statString) {
|
||
return;
|
||
}
|
||
|
||
// Special function to fix strange/weird copy/paste from PDF readers
|
||
// Unused up to now : this.fixWeirdPDF(statString);
|
||
|
||
// 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);
|
||
|
||
// Now start carac
|
||
let actorData = foundry.utils.deepClone(game.model.Actor[type]);
|
||
let items = [];
|
||
|
||
actorData.flags = { hautRevant: false, malusArmure: 0 }
|
||
for (let key in actorData.carac) {
|
||
let caracDef = actorData.carac[key];
|
||
// Parse the stat string for each caracteristic
|
||
let carac = XRegExp.exec(statString, XRegExp(caracDef.label + "\\s+(?<value>\\d+)", 'giu'));
|
||
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
|
||
}
|
||
if (type == "personnage") {
|
||
// Now process armors
|
||
await RdDStatBlockParser.parseArmors(statString, actorData, items);
|
||
}
|
||
|
||
// Get skills from compendium
|
||
await RdDStatBlockParser.parseCompetences(type, 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.setValeursActuelles(newActor, statString)
|
||
await newActor?.sheet.render(true)
|
||
}
|
||
|
||
static async parseCompetences(type, statString, actorData, items) {
|
||
const competences = await SystemCompendiums.getCompetences(type);
|
||
//console.log("Competences : ", competences);
|
||
for (let comp of competences) {
|
||
let compNameToSearch = RdDStatBlockParser.fixCompName(comp.name);
|
||
let compMatch = XRegExp.exec(statString, XRegExp("\\s" + compNameToSearch + compParser[type], 'giu'));
|
||
if (compMatch) {
|
||
comp = comp.toObject()
|
||
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 (type == "creature" || type == "entite") {
|
||
comp.system.carac_value = Number(compMatch.carac);
|
||
if (compMatch.dommages != undefined) {
|
||
comp.system.dommages = Number(compMatch.dommages);
|
||
comp.system.iscombat = true;
|
||
}
|
||
}
|
||
items.push(comp);
|
||
}
|
||
else if (type == "personnage") {
|
||
comp = comp.toObject();
|
||
items.push(comp);
|
||
}
|
||
}
|
||
}
|
||
|
||
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");
|
||
//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);
|
||
// now process the skill
|
||
if (weapMatch?.maniement) {
|
||
RdDStatBlockParser.setNiveauCompetenceArme(items, MANIEMENTS[weapMatch.maniement](weapon), niveau)
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
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) {
|
||
const sortName = Grammar.toLowerCaseNoAccent(matchSort.name).trim().replace("’", "'");
|
||
let sort = sorts.find(s => Grammar.toLowerCaseNoAccent(s.name) == sortName)
|
||
if (sort) {
|
||
actorData.flags.hautRevant = true
|
||
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}`)
|
||
}
|
||
});
|
||
|
||
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("(?<value>\\d+ kg)", 'giu'));
|
||
if (poids?.value) {
|
||
actorData.poids = poids.value;
|
||
}
|
||
// 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;
|
||
}
|
||
|
||
// Get beauty
|
||
const beaute = XRegExp.exec(statString, XRegExp("beauté\\s+(?<value>\\d+)", 'giu'));
|
||
if (beaute?.value) {
|
||
actorData.beaute = Number(beaute.value);
|
||
}
|
||
}
|
||
|
||
static parseCreature(statString, actorData) {
|
||
let protection = XRegExp.exec(statString, XRegExp("protection\\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 niveau = XRegExp.exec(statString, XRegExp("Niveau\\s+(?<value>[\\+\\-]?\\d+)", 'giu'))
|
||
let perception = XRegExp.exec(statString, XRegExp("perception\\s+(?<value>\\d+)", 'giu'))
|
||
if (perception?.value) {
|
||
return "creature"
|
||
}
|
||
if (niveau?.value) {
|
||
return "entite"
|
||
}
|
||
return "personnage"
|
||
}
|
||
|
||
static extractName(actorType, statString) {
|
||
switch (actorType) {
|
||
case "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);
|
||
}
|
||
}
|
||
const name = XRegExp.exec(statString, XRegExp("(?<value>.+)\\s+taille", 'giu'));
|
||
return Misc.upperFirst(name?.value || "Importé");
|
||
}
|
||
|
||
static warning(message) {
|
||
ui.notifications.warn(message);
|
||
}
|
||
|
||
}
|
||
|
||
/************************************************************************************/
|
||
// Some internal test strings
|
||
let statBlock01 = `+$16(/, baron de Sylvedire, né à l’heure 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
|
||
l’heure 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
|
||
l’heure 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 n’aboient pas. Les voyageurs
|
||
Vigilance
|
||
13
|
||
+3
|
||
`
|