Skip to content

Instantly share code, notes, and snippets.

@atondwal
Created July 4, 2026 22:20
Show Gist options
  • Select an option

  • Save atondwal/ebf143d77c8cf9b3050a2c726b0721dd to your computer and use it in GitHub Desktop.

Select an option

Save atondwal/ebf143d77c8cf9b3050a2c726b0721dd to your computer and use it in GitHub Desktop.
<title>Crowns of Eldervale</title>
<style>
:root{
--bg:#12161f; --sea:#0d1118; --panel:#1b212d; --panel2:#232a38;
--ink:#e8ddc3; --dim:#9a917a; --gold:#c9a24b; --bad:#b0563c; --good:#5e9c78;
--line:#333c4d;
}
*{box-sizing:border-box;margin:0}
html,body{height:100%}
body{
background:var(--bg); color:var(--ink);
font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;
font-size:15px;
}
#app{display:flex;flex-direction:column;height:100vh}
/* ---- top bar ---- */
#top{display:flex;align-items:center;gap:20px;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--line);flex-wrap:wrap}
#crest{font-variant:small-caps;letter-spacing:3px;color:var(--gold);font-weight:700;font-size:17px}
.stat{display:flex;flex-direction:column;line-height:1.2}
.stat small{color:var(--dim);font-variant:small-caps;letter-spacing:1px;font-size:11px}
.stat b{font-variant-numeric:tabular-nums;font-size:15px}
#endturn{margin-left:auto;background:var(--gold);color:#1a1408;border:0;padding:9px 20px;font:inherit;font-weight:700;cursor:pointer;border-radius:3px;letter-spacing:.5px}
#endturn:hover{filter:brightness(1.1)}
#restart{background:none;border:1px solid var(--line);color:var(--dim);padding:8px 12px;font:inherit;cursor:pointer;border-radius:3px}
#restart:hover{border-color:var(--gold);color:var(--gold)}
/* ---- main layout ---- */
main{flex:1;display:flex;min-height:0}
#left{flex:1.5;display:flex;flex-direction:column;min-width:0}
#mapwrap{flex:1;background:var(--sea);min-height:0;display:flex;padding:6px}
svg{width:100%;height:100%}
#log{height:150px;overflow-y:auto;padding:8px 14px;border-top:1px solid var(--line);font-size:13px;line-height:1.6;color:var(--dim)}
#log b{color:var(--gold);font-weight:400;font-variant:small-caps}
#panel{width:370px;border-left:1px solid var(--line);background:var(--panel);overflow-y:auto;padding:16px}
/* ---- map ---- */
polygon{stroke:var(--sea);stroke-width:2.5;cursor:pointer;opacity:.9}
polygon:hover{opacity:1}
polygon.sel{stroke:var(--gold);stroke-width:3;opacity:1}
polygon.dead{opacity:.5}
.mlabel{fill:#f4edd8;font-size:15px;text-anchor:middle;pointer-events:none;paint-order:stroke;stroke:rgba(0,0,0,.55);stroke-width:3px;font-family:inherit}
.mpow{font-size:11.5px;opacity:.85}
text.dead{opacity:.5}
/* ---- panel ---- */
#panel h3{font-variant:small-caps;letter-spacing:2px;color:var(--gold);font-weight:400;margin-bottom:10px;text-wrap:balance}
#panel h4{font-variant:small-caps;letter-spacing:1.5px;color:var(--gold);font-weight:400;margin:14px 0 6px}
.ccard{background:var(--panel2);border:1px solid var(--line);padding:12px;border-radius:3px}
.cname{font-size:17px}
.csub{color:var(--dim);font-size:13px;margin-top:3px}
.stats{display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;font-size:13px;font-variant-numeric:tabular-nums}
.chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
.chip{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:2px 9px;font-size:12px;color:var(--dim);font-variant:small-caps;letter-spacing:.5px}
.chip.rel{color:var(--ink);border-color:var(--gold)}
.opwrap{margin:12px 0 4px}
.oplabel{font-size:13px;color:var(--dim);margin-bottom:4px}
.oplabel b{font-variant-numeric:tabular-nums}
.pos{color:var(--good)} .neg{color:var(--bad)}
.opbar{height:7px;background:var(--bg);border:1px solid var(--line);border-radius:4px;overflow:hidden}
.opfill{height:100%}
.opfill.pos{background:var(--good)} .opfill.neg{background:var(--bad)}
#actions{display:flex;flex-direction:column;gap:6px}
.abtn{display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:100%;text-align:left;background:var(--panel2);border:1px solid var(--line);color:var(--ink);padding:8px 11px;font:inherit;font-size:14px;cursor:pointer;border-radius:3px}
.abtn small{color:var(--dim);font-size:11.5px;line-height:1.35}
.abtn:hover:not(:disabled){border-color:var(--gold)}
.abtn:disabled{opacity:.45;cursor:default}
.objbox{background:var(--panel2);border-left:2px solid var(--gold);padding:9px 12px;margin:8px 0;font-size:13.5px;line-height:1.5}
.objbox.war{border-left-color:var(--bad)}
.objbox p{margin:2px 0}
.hint{color:var(--dim);font-style:italic;font-size:13px;margin-top:12px;line-height:1.5}
/* ---- modal ---- */
#modal{position:fixed;inset:0;background:rgba(5,7,12,.75);display:flex;align-items:center;justify-content:center;z-index:9;padding:16px}
#mbox{background:var(--panel);border:1px solid var(--gold);max-width:680px;width:100%;max-height:88vh;overflow-y:auto;padding:24px;border-radius:4px}
#mtitle{font-variant:small-caps;letter-spacing:2px;color:var(--gold);font-weight:400;margin-bottom:10px;text-wrap:balance}
.mint{line-height:1.6;margin-bottom:8px}
#mbtns{display:flex;flex-direction:column;gap:8px;margin-top:14px}
#mbtns button{background:var(--panel2);border:1px solid var(--line);color:var(--ink);padding:10px 14px;font:inherit;text-align:left;cursor:pointer;border-radius:3px}
#mbtns button:hover:not(:disabled){border-color:var(--gold)}
#mbtns button:disabled{opacity:.45;cursor:default}
#pickgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:12px}
.pickcard{background:var(--panel2);border:1px solid var(--line);padding:11px;cursor:pointer;border-radius:3px;display:flex;flex-direction:column;gap:3px}
.pickcard:hover{border-color:var(--gold)}
.pickcard small{color:var(--dim);font-size:12px}
.pickcard .traits{font-variant:small-caps;letter-spacing:.5px}
.dot{width:11px;height:11px;border-radius:50%;display:inline-block;margin-bottom:2px}
.hidden{display:none!important}
button:focus-visible,.pickcard:focus-visible{outline:2px solid var(--gold);outline-offset:1px}
@media (prefers-reduced-motion:no-preference){polygon{transition:opacity .15s,stroke .15s}}
@media (max-width:900px){
main{flex-direction:column}
#panel{width:100%;border-left:0;border-top:1px solid var(--line)}
#mapwrap{min-height:340px}
#pickgrid{grid-template-columns:1fr}
}
</style>
<div id="app">
<header id="top">
<div id="crest">✦ Crowns of Eldervale</div>
<div class="stat"><small>Season</small><b id="date">β€”</b></div>
<div class="stat"><small>Gold</small><b id="gold">β€”</b></div>
<div class="stat"><small>Prestige</small><b id="prest">β€”</b></div>
<div class="stat"><small>Realm power</small><b id="power">β€”</b></div>
<div class="stat"><small>Ruler</small><b id="myrealm">β€”</b></div>
<button id="endturn">End Season β–Έ</button>
<button id="restart" title="Begin a new saga">β†Ί New saga</button>
</header>
<main>
<div id="left">
<div id="mapwrap"><svg id="map" viewBox="0 0 800 560" role="img" aria-label="Map of Eldervale"></svg></div>
<div id="log"></div>
</div>
<aside id="panel"><p class="hint">Select a realm on the map to open its court.</p></aside>
</main>
</div>
<div id="modal" class="hidden"><div id="mbox"><h2 id="mtitle"></h2><div id="mbody"></div><div id="mbtns"></div></div></div>
<script>
'use strict';
/* ============ helpers ============ */
const $ = id => document.getElementById(id);
const rnd = (a,b) => a + Math.floor(Math.random()*(b-a+1));
const pick = arr => arr[Math.floor(Math.random()*arr.length)];
const chance = p => Math.random() < p;
const clamp = (v,a,b) => Math.max(a, Math.min(b, v));
const pairKey = (a,b) => a < b ? a+'|'+b : b+'|'+a;
/* ============ static data ============ */
const SEASONS = ['Spring','Summer','Autumn','Winter'];
const RACES = {Human:{life:70}, Elf:{life:300}, Dwarf:{life:130}, Orc:{life:55}, Dragonkin:{life:160}, Fae:{life:220}};
const TITLES = {Human:['King','Queen'], Elf:['Highlord','Highlady'], Dwarf:['Thane','Thane'],
Orc:['Warchief','Warchief'], Dragonkin:['Ascendant','Ascendant'], Fae:['Sovereign','Sovereign']};
const REL = {
'Human|Human':10,'Elf|Human':0,'Dwarf|Human':5,'Human|Orc':-25,'Dragonkin|Human':-10,'Fae|Human':-5,
'Elf|Elf':15,'Dwarf|Elf':-20,'Elf|Orc':-35,'Dragonkin|Elf':0,'Elf|Fae':15,
'Dwarf|Dwarf':15,'Dwarf|Orc':-30,'Dragonkin|Dwarf':-15,'Dwarf|Fae':-10,
'Orc|Orc':10,'Dragonkin|Orc':5,'Fae|Orc':-15,
'Dragonkin|Dragonkin':10,'Dragonkin|Fae':0,'Fae|Fae':10
};
const TRAITS = {
brave:{opp:'craven',mod:{m:3}}, craven:{opp:'brave',mod:{i:1,m:-2}},
ambitious:{opp:'content',mod:{d:1,i:1,m:1}}, content:{opp:'ambitious',mod:{d:1}},
honest:{opp:'deceitful',mod:{d:2}}, deceitful:{opp:'honest',mod:{i:3}},
kind:{opp:'cruel',mod:{d:2}}, cruel:{opp:'kind',mod:{i:1,m:1}},
gregarious:{opp:'shy',mod:{d:3}}, shy:{opp:'gregarious',mod:{i:1,d:-1}},
wrathful:{opp:'calm',mod:{m:2,d:-1}}, calm:{opp:'wrathful',mod:{d:2}},
greedy:{opp:'generous',mod:{i:1}}, generous:{opp:'greedy',mod:{d:2}},
};
const NAMES = {
Human:{m:['Aldric','Bram','Cedric','Doran','Edmund','Gareth','Hale','Osric','Roland','Theo'],
f:['Adela','Brienne','Clara','Elin','Gwen','Isolde','Maren','Rosalind','Sable','Ysra'],
dyn:['Blackwood','Harrow','Valemont','Ashford','Crane']},
Elf:{m:['Aerendil','Caladrel','Faelar','Ilthuryn','Sylvaris','Thandriel'],
f:['Aelene','Ithilwen','Lyriel','Naivara','Sariel','Vaelis'],
dyn:['Moonshadow','Dawnwhisper','Silverbough','Starweaver']},
Dwarf:{m:['Borin','Dagur','Grimnir','Keldan','Thorvald','Ulfgar'],
f:['Astrid','Brunhild','Dagny','Hertha','Sigrun','Torva'],
dyn:['Ironvein','Stonehelm','Deepdelver','Goldbeard']},
Orc:{m:['Drakh','Ghorbash','Karguk','Morzul','Ragash','Urzoth'],
f:['Bashuk','Ghra','Lazgar','Shel','Urzul','Vola'],
dyn:['Bloodfang','Skullrend','Ashmaw','Ironhide']},
Dragonkin:{m:['Arjhan','Balasar','Kriv','Medrash','Rhogar','Torinn'],
f:['Akra','Farideh','Jheri','Kava','Sora','Thava'],
dyn:['Emberscale','Cindwyrm','Goldcrest','Stormtalon']},
Fae:{m:['Corin','Finvarra','Oberic','Puck','Rowan','Thistle'],
f:['Aine','Fionna','Maeve','Nimue','Saoirse','Titania'],
dyn:['Gloamthorn','Dewlight','Nightbell','Willowisp']},
};
const REALM_DEFS = [
{name:'Frostgard', race:'Human', color:'#6f96b8', lx:140, ly:95, pts:'0,0 300,0 285,70 305,130 290,190 140,178 0,192'},
{name:'Silverwood', race:'Elf', color:'#5e9c66', lx:425, ly:95, pts:'300,0 560,0 545,80 565,140 550,190 400,203 290,190 305,130 285,70'},
{name:'KragdΓ»n', race:'Dwarf', color:'#a3874f', lx:680, ly:90, pts:'560,0 800,0 800,198 660,190 550,190 565,140 545,80'},
{name:'Tidehold', race:'Human', color:'#4f9ea6', lx:100, ly:285, pts:'0,192 140,178 210,190 196,270 214,330 200,385 92,372 0,382'},
{name:'Aldermark', race:'Human', color:'#b3812e', lx:320, ly:290, pts:'210,190 290,190 400,203 430,196 444,270 426,330 440,390 300,382 200,385 214,330 196,270'},
{name:'Duskmere', race:'Elf', color:'#7b6aa6', lx:540, ly:288, pts:'430,196 550,190 660,190 632,270 654,340 636,390 520,382 440,390 426,330 444,270'},
{name:'Emberpeak', race:'Dragonkin', color:'#b0563c', lx:720, ly:292, pts:'660,190 800,198 800,390 636,390 654,340 632,270'},
{name:'Thornvale', race:'Fae', color:'#8a9a4a', lx:140, ly:470, pts:'0,382 92,372 200,385 300,382 282,470 296,560 0,560'},
{name:'Sunspire', race:'Human', color:'#c2a24b', lx:410, ly:472, pts:'300,382 440,390 520,382 542,470 526,560 296,560 282,470'},
{name:'Ashfall', race:'Orc', color:'#7d6d63', lx:665, ly:475, pts:'520,382 636,390 800,390 800,560 526,560 542,470'},
];
/* ============ state ============ */
let S = null; // pure data β€” everything saved to localStorage
const els = {}; // realm id -> {poly, name, pow} SVG nodes
const title = c => TITLES[c.race][c.g === 'm' ? 0 : 1];
const alive = () => S.realms.filter(r => r.alive);
const others = id => S.realms.filter(r => r.alive && r.id !== id).map(r => r.id);
const allied = (a,b) => !!S.allies[pairKey(a,b)];
const tied = (a,b) => !!S.ties[pairKey(a,b)];
const coun = (rid, role) => S.realms[rid].court.find(c => c.role === role);
const kin = rid => S.realms[rid].court.filter(c => c.role === 'kin');
const unwedKin = rid => kin(rid).filter(c => !c.married);
const eldest = cs => cs.reduce((a,b) => b.age > a.age ? b : a);
const dipl = rid => Math.round((S.realms[rid].ruler.d + coun(rid,'chancellor').d) / 2); // the court speaking
const intr = rid => Math.round((S.realms[rid].ruler.i + coun(rid,'spymaster').i) / 2); // the court scheming
const warBetween = (a,b) => S.wars.find(w => (w.a===a && w.d===b) || (w.a===b && w.d===a));
const atWarAny = id => S.wars.some(w => w.a===id || w.d===id);
const rn = R => S.realms[R].ruler.name + ' of ' + S.realms[R].name;
const other2 = R => { const p = others(S.p).filter(x => x !== R); return p.length ? pick(p) : R; };
const totalPower = () => alive().reduce((s,r) => s + r.power, 0);
function opAdd(who, about, amt){
if (S.op[who] && S.op[who][about] !== undefined)
S.op[who][about] = clamp(S.op[who][about] + amt, -100, 100);
}
function newChar(race, dyn){
const g = chance(0.5) ? 'm' : 'f';
const life = RACES[race].life;
const keys = Object.keys(TRAITS);
const traits = [];
while (traits.length < 3){
const t = pick(keys);
if (!traits.includes(t) && !traits.includes(TRAITS[t].opp)) traits.push(t);
}
const c = {cid:S.cid++, name:pick(NAMES[race][g]), dyn:dyn || pick(NAMES[race].dyn), race, g,
age:rnd(Math.round(life*0.3), Math.round(life*0.75)),
d:rnd(4,14), i:rnd(4,14), m:rnd(4,14),
traits, married:chance(0.6)};
for (const t of traits){
const mod = TRAITS[t].mod;
c.d = clamp(c.d + (mod.d||0), 1, 20);
c.i = clamp(c.i + (mod.i||0), 1, 20);
c.m = clamp(c.m + (mod.m||0), 1, 20);
}
return c;
}
function newCourtier(race, role, dyn){
const c = newChar(race, dyn), life = RACES[race].life;
c.role = role; // 'chancellor'|'spymaster'|'marshal'|'kin'
c.age = role === 'kin' ? rnd(16, Math.round(life*0.35))
: rnd(Math.round(life*0.25), Math.round(life*0.6));
if (role === 'kin') c.married = false; // kin are the marriage pool
return c;
}
function newCourt(r){
const court = ['chancellor','spymaster','marshal'].map(role => newCourtier(r.race, role, null));
for (let n = rnd(1,2); n > 0; n--) court.push(newCourtier(r.race, 'kin', r.ruler.dyn));
return court;
}
function baseOpinion(a, b){
const A = S.realms[a].ruler, B = S.realms[b].ruler;
let v = REL[pairKey(A.race, B.race)] || 0;
for (const ta of A.traits) for (const tb of B.traits){
if (ta === tb) v += 8;
else if (TRAITS[ta].opp === tb) v -= 12;
}
if (B.traits.includes('kind')) v += 5;
if (B.traits.includes('cruel')) v -= 5;
return clamp(v + rnd(-12,12), -70, 70);
}
function newGame(){
S = {v:2, cid:1, realms:REALM_DEFS.map((d,i) => ({id:i, name:d.name, race:d.race, color:d.color,
alive:true, absorbedBy:null, power:rnd(60,100), ruler:null, court:[]})),
base:{}, op:{}, allies:{}, ties:{}, wars:[], sway:[], cds:{},
season:0, year:1123, p:null, gold:120, prestige:0, sel:null, over:false, log:[]};
for (const r of S.realms){ r.ruler = newChar(r.race); r.court = newCourt(r); }
for (let a = 0; a < S.realms.length; a++){
S.base[a] = {}; S.op[a] = {};
for (let b = 0; b < S.realms.length; b++) if (a !== b){
S.base[a][b] = baseOpinion(a,b);
S.op[a][b] = S.base[a][b];
}
}
}
function save(){ try{ localStorage.setItem('eldervale', JSON.stringify(S)); }catch(e){} }
function load(){
try{
const s = localStorage.getItem('eldervale');
if (s){ const d = JSON.parse(s); if (d && d.v === 2){ S = d; return true; } }
}catch(e){}
return false;
}
/* ============ log & modal ============ */
function addLog(txt){
S.log.unshift(`<b>${SEASONS[S.season]} ${S.year}</b> β€” ${txt}`);
if (S.log.length > 150) S.log.pop();
$('log').innerHTML = S.log.map(l => `<div>${l}</div>`).join('');
}
function showModal(t, bodyHTML, btns){
$('mtitle').textContent = t;
$('mbody').innerHTML = bodyHTML;
const bb = $('mbtns'); bb.innerHTML = '';
for (const b of btns){
const el = document.createElement('button');
el.textContent = b.label;
el.disabled = !!b.disabled;
el.onclick = () => { hideModal(); if (b.fn) b.fn(); };
bb.appendChild(el);
}
$('modal').classList.remove('hidden');
}
function hideModal(){ $('modal').classList.add('hidden'); }
/* ============ wars ============ */
function sidePower(id, enemy){
let p = S.realms[id].power;
for (const b of others(id))
if (allied(id,b) && !allied(b,enemy)) p += S.realms[b].power * 0.5;
return p;
}
function declareWar(a, d){
S.wars.push({a, d, score:0});
delete S.allies[pairKey(a,d)];
opAdd(d, a, -40);
S.base[d][a] = clamp((S.base[d][a]||0) - 25, -70, 70);
if (a === S.p) S.prestige -= 3;
addLog(`βš” ${S.realms[a].name} declares war on ${S.realms[d].name}!`);
}
function transfer(from, to, frac){
const F = S.realms[from], T = S.realms[to];
const amt = Math.round(F.power * frac);
F.power -= amt; T.power += amt;
if (F.power < 18) annex(from, to);
}
function severTies(cid, name){
for (const k in S.ties){
const tie = S.ties[k];
if (tie.ac !== cid && tie.bc !== cid) continue;
delete S.ties[k];
const [a, b] = k.split('|').map(Number);
opAdd(a, b, -5); opAdd(b, a, -5);
addLog(`πŸ’ The pact binding ${S.realms[a].name} and ${S.realms[b].name} lapses with the death of ${name}.`);
}
}
function annex(from, to){
const F = S.realms[from], T = S.realms[to];
T.power += F.power; F.power = 0;
F.alive = false; F.absorbedBy = to;
S.wars = S.wars.filter(w => w.a !== from && w.d !== from);
S.sway = S.sway.filter(s => s.t !== from);
for (const k in S.ties) if (k.split('|').map(Number).includes(from)) delete S.ties[k];
if (S.sel === from) S.sel = null;
addLog(`βš‘ ${F.name} is no more β€” annexed by ${T.name}.`);
if (from === S.p) endGame(false, `Your realm has been conquered by ${T.name}. The chronicle closes.`);
}
function endWar(w, win, lose){
S.wars = S.wars.filter(x => x !== w);
addLog(`βš” ${S.realms[win].name} triumphs over ${S.realms[lose].name}.`);
opAdd(lose, win, -30);
if (win === S.p) S.prestige += 15;
if (lose === S.p) S.prestige -= 10;
transfer(lose, win, 0.33);
}
function tickWars(){
for (const w of [...S.wars]){
const pa = sidePower(w.a, w.d), pd = sidePower(w.d, w.a);
w.score = clamp(w.score + (pa - pd)/12 + rnd(-7,7), -110, 110);
if (w.score >= 100) endWar(w, w.a, w.d);
else if (w.score <= -100) endWar(w, w.d, w.a);
}
}
/* ============ succession & aging ============ */
function succession(rid, cause){
const r = S.realms[rid];
const old = r.ruler;
if (rid === S.p && !old.heir){
addLog(`${title(old)} ${old.name} ${cause}.`);
endGame(false, `${title(old)} ${old.name} ${cause} with no heir. House ${old.dyn} is ended, and the chronicle with it.`);
return;
}
const c = newChar(r.race, old.heir ? old.dyn : null);
c.age = rnd(16, Math.round(RACES[r.race].life * 0.3));
c.heir = false;
r.ruler = c;
for (const b of others(rid)){
S.base[rid][b] = baseOpinion(rid,b); S.op[rid][b] = S.base[rid][b];
S.base[b][rid] = baseOpinion(b,rid); S.op[b][rid] = S.base[b][rid];
}
if (rid === S.p)
addLog(`${old.name} ${cause}. You now rule as ${title(c)} ${c.name} ${c.dyn}. Old friendships must be won anew.`);
else
addLog(`${old.name} of ${r.name} ${cause}. ${old.heir ? c.name + ' ' + c.dyn + ' inherits the throne.' : 'A new dynasty, House ' + c.dyn + ', seizes the throne.'}`);
}
function yearly(){
for (const r of [...alive()]){
const c = r.ruler, life = RACES[c.race].life;
c.age++;
let dp = 0.004;
if (c.age > life * 0.7) dp += Math.pow((c.age - life*0.7)/(life*0.35), 2) * 0.5;
if (chance(dp)){ succession(r.id, 'has died'); continue; }
if (!c.heir && chance(c.married ? 0.18 : 0.07)){
c.heir = true;
if (r.id === S.p) addLog(c.married ? 'Joy in your hall β€” an heir is born to your line.'
: 'A kinsman comes of age at your court. You have an heir.');
}
}
}
/* ============ player actions ============ */
const ACTIONS = [
{id:'praise', n:'Praise', g:0, cd:3, tip:'Flattering words from your envoy. A small, free warmth.'},
{id:'gift', n:'Send Gift', g:60, cd:4, tip:'Gold speaks. +20 opinion.'},
{id:'sway', n:'Sway Court', g:30, cd:8, tip:'Your envoy lingers at their court: +3 opinion each season for 6 seasons.'},
{id:'marry', n:'Marriage Pact', g:100, cd:0, tip:'Bind your dynasties. +35 opinion and unlocks an alliance.'},
{id:'ally', n:'Form Alliance', g:0, cd:0, tip:'They fight beside you. Needs opinion 30+, or a marriage pact.'},
{id:'insult', n:'Insult', g:0, cd:3, tip:'A public slight. βˆ’25 opinion, +5 prestige (+10 against a rival).'},
{id:'rumor', n:'Spread Rumor', g:40, cd:5, tip:'Poison another court against them. Success rests on your intrigue.'},
{id:'plot', n:'Plot Assassination', g:150, cd:12, tip:'A knife in the dark. Discovery means disgrace β€” or war.'},
{id:'war', n:'Declare War', g:0, cd:0, tip:'Press your claim by force. The victor seizes a third of the loser\'s power.'},
{id:'peace', n:'Sue for Peace', g:0, cd:0, tip:'End the war on current terms: victory at +60, surrender at βˆ’60, else white peace.'},
];
// Returns null if allowed, 'hide' to omit the button, or a reason string to disable it.
function why(aid, t){
const act = ACTIONS.find(x => x.id === aid);
const w = warBetween(S.p, t);
if (aid === 'peace') return w ? null : 'hide';
if (aid === 'war'){
if (w) return 'hide';
return allied(S.p, t) ? 'You are sworn allies' : null;
}
if (w && ['praise','gift','sway','marry','ally'].includes(aid)) return 'Not while at war';
if (aid === 'ally' && allied(S.p, t)) return 'hide';
if (act.g > S.gold) return 'Needs ' + act.g + ' gold';
if (S.cds[aid + ':' + t] > 0) return 'Ready in ' + S.cds[aid + ':' + t] + ' season(s)';
if (aid === 'marry' && tied(S.p, t)) return 'Dynasties already bound';
if (aid === 'ally' && !(S.op[t][S.p] >= 30 || tied(S.p, t)))
return 'They think too little of you (needs opinion 30, or a marriage pact)';
return null;
}
function act(aid, t){
if (S.over || why(aid, t)) return;
const a = ACTIONS.find(x => x.id === aid);
S.gold -= a.g;
if (a.cd) S.cds[aid + ':' + t] = a.cd;
const T = S.realms[t], me = S.realms[S.p], pc = me.ruler, tc = T.ruler;
switch (aid){
case 'praise':
opAdd(t, S.p, 6 + Math.floor(pc.d/4));
addLog(`Your envoy heaps praise on ${tc.name} of ${T.name}.`);
break;
case 'gift':
opAdd(t, S.p, 20);
addLog(`A caravan of gifts arrives in ${T.name}. ${tc.name} is pleased.`);
break;
case 'sway':
S.sway.push({t, left:6});
addLog(`Your envoy settles into the court of ${T.name} to sway ${tc.name}.`);
break;
case 'marry':
S.ties[pairKey(S.p,t)] = 1;
opAdd(t, S.p, 35);
S.base[t][S.p] = clamp(S.base[t][S.p] + 15, -70, 70);
S.prestige += 5;
addLog(`Wedding bells across the marches β€” your dynasty is bound to House ${tc.dyn} of ${T.name}.`);
break;
case 'ally':
S.allies[pairKey(S.p,t)] = 1;
S.base[t][S.p] = clamp(S.base[t][S.p] + 10, -70, 70);
S.prestige += 10;
addLog(`🀝 ${T.name} swears an alliance with you.`);
break;
case 'insult':
S.prestige += S.op[t][S.p] < -40 ? 10 : 5;
opAdd(t, S.p, -25);
addLog(`Your herald delivers a stinging insult to ${tc.name}. The courts titter.`);
break;
case 'rumor': {
const pool = others(S.p).filter(x => x !== t);
if (!pool.length){ addLog('There is no court left to whisper in.'); break; }
const r = pick(pool);
const ch = clamp(40 + 3*(pc.i - tc.i), 10, 85);
if (rnd(1,100) <= ch){
opAdd(r, t, -20);
addLog(`Whispers take root in ${S.realms[r].name}: ${S.realms[r].ruler.name} now looks coldly on ${tc.name}.`);
} else if (chance(0.5)){
opAdd(t, S.p, -20);
addLog(`Your rumor is traced back to your court. ${tc.name} is furious.`);
} else addLog('Your rumor dies quietly, unheard.');
break;
}
case 'plot': {
const ch = clamp(15 + 4*(pc.i - tc.i), 5, 70);
if (rnd(1,100) <= ch){
addLog(`${tc.name} of ${T.name} is found dead in the night. None can prove a thing.`);
if (chance(0.35)){
for (const b of others(S.p)) opAdd(b, S.p, -10);
addLog('…yet whispers in every court name you the hand behind it.');
}
succession(t, 'was slain by an unknown hand');
} else if (chance(0.6)){
opAdd(t, S.p, -60);
S.prestige -= 15;
addLog(`Your assassin is caught! ${tc.name} knows whose coin bought the knife.`);
if (T.power > me.power * 0.8 && !warBetween(S.p, t)) declareWar(t, S.p);
} else addLog('Your assassin loses nerve and slips away. No one is the wiser.');
break;
}
case 'war':
declareWar(S.p, t);
break;
case 'peace': {
const w = warBetween(S.p, t);
const my = w.a === S.p ? w.score : -w.score;
S.wars = S.wars.filter(x => x !== w);
if (my >= 60){ S.prestige += 10; addLog(`${T.name} concedes β€” you dictate a victor's peace.`); transfer(t, S.p, 0.2); }
else if (my <= -60){ S.prestige -= 10; addLog(`You bend the knee. A costly peace with ${T.name}.`); transfer(S.p, t, 0.2); }
else { S.prestige -= 5; opAdd(t, S.p, 10); addLog(`White peace with ${T.name}. The matter rests, for now.`); }
break;
}
}
save(); render();
}
/* ============ AI ============ */
function aiTurn(){
for (const r of alive()){
if (r.id === S.p || !chance(0.55)) continue;
const a = r.id, pool = others(a);
if (!pool.length) continue;
const t = pick(pool), o = S.op[a][t];
const hawk = r.ruler.traits.includes('ambitious') || r.ruler.traits.includes('wrathful');
if (o < -45 && !allied(a,t) && !warBetween(a,t) && !atWarAny(a)
&& r.power > S.realms[t].power * 1.15 && chance(hawk ? 0.2 : 0.1)){
declareWar(a, t);
} else if (o > 35 && S.op[t][a] > 25 && !allied(a,t) && !warBetween(a,t) && chance(0.25)){
S.allies[pairKey(a,t)] = 1;
addLog(`🀝 ${r.name} and ${S.realms[t].name} swear an alliance.`);
} else if (o > 5 && chance(0.15)){
opAdd(t, a, 12);
if (t === S.p) addLog(`${r.ruler.name} of ${r.name} sends you a generous gift of ${r.race === 'Dwarf' ? 'wrought gold' : 'fine horses'}.`);
} else if (o < -25 && chance(0.15)){
opAdd(t, a, -12);
if (t === S.p) addLog(`${r.ruler.name} of ${r.name} mocks you before the assembled courts.`);
}
}
}
/* ============ events ============ */
const EVENTS = [
{t:'A Grand Tourney', b:R=>`${rn(R)} proclaims a grand tourney and invites champions from every realm.`, ch:[
{l:'Attend with a lavish retinue (βˆ’40 gold)', g:40, f:R=>{S.gold-=40; opAdd(R,S.p,15); S.prestige+=5; return 'Your champions take the laurels. The courts speak your name warmly.';}},
{l:'Decline politely', f:R=>{opAdd(R,S.p,-5); return `${rn(R)} marks your empty seat.`;}}]},
{t:'Feast of the Long Night', b:()=>'The longest night of the year approaches, when crowns traditionally open their halls.', ch:[
{l:'Host a great feast (βˆ’60 gold)', g:60, f:R=>{S.gold-=60; opAdd(R,S.p,12); opAdd(other2(R),S.p,12); S.prestige+=4; return 'Your hall blazes with light; two courts leave as friends.';}},
{l:'Keep your hall dark', f:R=>{opAdd(R,S.p,-4); return 'Travelers carry word of your cold hearth.';}}]},
{t:'A Dragon Over the Marches', b:()=>'A young dragon has taken to burning granaries along your border.', ch:[
{l:'Ride against it yourself', f:()=>{const c=S.realms[S.p].ruler; if(c.m>=12){S.prestige+=12; return 'You slay the wyrm. The songs are already spreading.';} S.prestige-=5; return 'The beast drives you back, singed and humbled.';}},
{l:'Hire dragonslayers (βˆ’50 gold)', g:50, f:()=>{S.gold-=50; S.prestige+=6; return 'The slayers mount the head above your gate.';}}]},
{t:'The Wandering Bard', b:R=>`A famed bard offers to carry a ballad to the court of ${rn(R)}.`, ch:[
{l:'Pay for a flattering ballad (βˆ’25 gold)', g:25, f:R=>{S.gold-=25; opAdd(R,S.p,12); return `The ballad delights the court of ${S.realms[R].name}.`;}},
{l:'Have him sing of your own glory', f:()=>{S.prestige+=3; return 'Your name rings in taverns across Eldervale.';}}]},
{t:'Border Skirmish', b:R=>`Your riders clash with hunters sworn to ${rn(R)}. Blood is spilled.`, ch:[
{l:'Pay weregild (βˆ’30 gold)', g:30, f:R=>{S.gold-=30; opAdd(R,S.p,10); return 'The dead are paid for; tempers cool.';}},
{l:'Stand firm', f:R=>{opAdd(R,S.p,-15); S.prestige+=5; return 'You yield nothing. Their envoy departs in fury.';}}]},
{t:'A Suspicious Gift', b:R=>`An unmarked cask of elderwine arrives, said to come from ${rn(R)}.`, ch:[
{l:'Drink deep', f:R=>{if(chance(0.25)){opAdd(R,S.p,-20); S.prestige-=5; return 'Poison! You survive, barely. Someone will answer for this.';} S.prestige+=3; return 'A superb vintage. The rumor was baseless.';}},
{l:'Pour it into the sea', f:R=>{opAdd(R,S.p,-5); return 'If the gift was honest, word of the insult will travel.';}}]},
{t:'Refugees of War', b:()=>'Ragged columns from the war-torn marches beg leave to settle in your lands.', ch:[
{l:'Shelter them (βˆ’30 gold)', g:30, f:R=>{S.gold-=30; S.prestige+=6; opAdd(R,S.p,8); return 'They bless your name. The courts take note of your mercy.';}},
{l:'Bar the gates', f:R=>{opAdd(R,S.p,-8); return 'They trudge on. Minstrels make of it a sad song.';}}]},
{t:'The Hedge-Wizard', b:()=>'A ragged wizard at your gate offers blessings β€” for a price.', ch:[
{l:'Pay for a blessing (βˆ’20 gold)', g:20, f:R=>{S.gold-=20; opAdd(R,S.p,10); return `He carries kind words of you to the court of ${S.realms[R].name}.`;}},
{l:'Turn him away', f:()=>{if(chance(0.3)){opAdd(pick(others(S.p)),S.p,-5); opAdd(pick(others(S.p)),S.p,-5); return 'He curses your name at every crossroads.';} return 'He shuffles off, muttering.';}}]},
{t:'Trade Delegation', b:R=>`Merchants under the banner of ${rn(R)} seek a charter in your ports.`, ch:[
{l:'Grant the charter', f:R=>{S.gold+=40; opAdd(R,S.p,8); return 'Trade flows; both courts profit.';}},
{l:'Levy heavy tariffs', f:R=>{S.gold+=80; opAdd(R,S.p,-12); return 'Your coffers swell. Their merchants seethe.';}}]},
{t:'A Faerie Bargain', b:()=>'A creature of the twilight courts offers a chest of faerie gold β€” for nothing at all, it says.', ch:[
{l:'Take the gold', f:R=>{S.gold+=80; opAdd(R,S.p,-6); opAdd(other2(R),S.p,-6); return 'The gold is real. So are the uneasy whispers that follow it.';}},
{l:'Refuse politely', f:()=>{S.prestige+=4; return 'It smiles too widely and departs. You feel wiser.';}}]},
];
function maybeEvent(){
if (!chance(0.3)) return;
const pool = others(S.p);
if (!pool.length) return;
const R = pick(pool), ev = pick(EVENTS);
showModal(ev.t, `<p class="mint">${ev.b(R)}</p>`, ev.ch.map(c => ({
label:c.l, disabled:(c.g||0) > S.gold,
fn:() => { const res = c.f(R); if (res) addLog(res); save(); render(); }
})));
}
/* ============ turn loop ============ */
function endTurn(){
if (S.p === null || S.over) return;
for (const s of S.sway){ opAdd(s.t, S.p, 3); s.left--; }
S.sway = S.sway.filter(s => s.left > 0 && S.realms[s.t].alive);
aiTurn();
tickWars();
if (!S.over) maybeEvent();
// opinions drift back toward their base, one point a season
for (const a of others(-1)) for (const b of others(a)){
if (S.op[a][b] < S.base[a][b]) S.op[a][b]++;
else if (S.op[a][b] > S.base[a][b]) S.op[a][b]--;
}
for (const k in S.cds) if (S.cds[k] > 0) S.cds[k]--;
S.season++;
if (S.season > 3){ S.season = 0; S.year++; if (!S.over) yearly(); }
S.gold += 15 + Math.floor(S.realms[S.p].power / 5);
checkVictory();
save(); render();
}
function checkVictory(){
if (S.over) return;
const mine = S.realms[S.p].power;
const rest = others(S.p);
if (mine / totalPower() >= 0.5)
return endGame(true, 'Half of Eldervale bows to your banner. The High Crown is yours by conquest.');
if (rest.length && rest.every(b => allied(S.p, b)))
return endGame(true, 'Every living crown is sworn to you. The High Crown is yours by friendship β€” a thing no conqueror ever held.');
}
function endGame(win, msg){
S.over = true; save();
showModal(win ? 'πŸ‘‘ The High Crown' : 'The Crown Falls', `<p class="mint">${msg}</p>`,
[{label:'Begin a new saga', fn:restart}]);
}
function restart(){
try{ localStorage.removeItem('eldervale'); }catch(e){}
newGame();
showStart();
}
/* ============ rendering ============ */
function buildMap(){
const svg = $('map'), NS = 'http://www.w3.org/2000/svg';
REALM_DEFS.forEach((d, i) => {
const poly = document.createElementNS(NS, 'polygon');
poly.setAttribute('points', d.pts);
poly.setAttribute('fill', d.color);
poly.addEventListener('click', () => { S.sel = i; render(); });
svg.appendChild(poly);
const name = document.createElementNS(NS, 'text');
name.setAttribute('x', d.lx); name.setAttribute('y', d.ly);
name.setAttribute('class', 'mlabel');
name.textContent = d.name;
const pow = document.createElementNS(NS, 'text');
pow.setAttribute('x', d.lx); pow.setAttribute('y', d.ly + 17);
pow.setAttribute('class', 'mlabel mpow');
svg.appendChild(name); svg.appendChild(pow);
els[i] = {poly, name, pow};
});
}
function charCard(r){
const c = r.ruler;
return `<div class="ccard"><div class="cname">${title(c)} ${c.name} ${c.dyn}</div>
<div class="csub">${r.name} Β· ${c.race} Β· ${c.age} years</div>
<div class="stats"><span>Diplomacy ${c.d}</span><span>Intrigue ${c.i}</span><span>Martial ${c.m}</span><span>Power ${r.power}</span></div>
<div class="chips">${c.traits.map(t => `<span class="chip">${t}</span>`).join('')}</div>
<div class="csub">${c.married ? 'Married' : 'Unwed'} Β· ${c.heir ? 'Has an heir' : 'No heir'}</div></div>`;
}
function renderPanel(){
const el = $('panel');
if (S.p === null) return;
const t = S.sel;
if (t === null || t === S.p){
const me = S.realms[S.p];
const allies = others(S.p).filter(b => allied(S.p, b));
const myWars = S.wars.filter(w => w.a === S.p || w.d === S.p);
el.innerHTML = `<h3>Your Court</h3>${charCard(me)}
<div class="objbox"><h4>Paths to the High Crown</h4>
<p>βš” Conquest β€” hold half of Eldervale's power: <b>${Math.round(100*me.power/totalPower())}%</b> of 50%</p>
<p>🀝 Friendship β€” ally every living crown: <b>${allies.length}</b> of ${others(S.p).length}</p></div>
${allies.length ? `<div class="objbox"><h4>Allies</h4><p>${allies.map(b => S.realms[b].name).join(', ')}</p></div>` : ''}
${myWars.map(w => { const e2 = w.a === S.p ? w.d : w.a; const my = w.a === S.p ? w.score : -w.score;
return `<div class="objbox war"><h4>War with ${S.realms[e2].name}</h4><p>War score <b>${Math.round(my)}</b> β€” the war ends at Β±100, or sue for peace.</p></div>`; }).join('')}
<p class="hint">Select another realm on the map to open its court.</p>`;
return;
}
const r = S.realms[t];
if (!r.alive){
el.innerHTML = `<h3>${r.name}</h3><p class="hint">These lands now answer to ${S.realms[r.absorbedBy].name}.</p>`;
return;
}
const o = S.op[t][S.p];
const rel = [];
if (allied(S.p, t)) rel.push('🀝 Ally');
if (tied(S.p, t)) rel.push('πŸ’ Bound by marriage');
if (warBetween(S.p, t)) rel.push('βš” At war');
const theirAllies = others(t).filter(b => b !== S.p && allied(t, b)).map(b => S.realms[b].name);
el.innerHTML = `<h3>Court of ${r.name}</h3>${charCard(r)}
<div class="opwrap"><div class="oplabel">Their opinion of you <b class="${o >= 0 ? 'pos' : 'neg'}">${o > 0 ? '+' : ''}${o}</b></div>
<div class="opbar"><div class="opfill ${o >= 0 ? 'pos' : 'neg'}" style="width:${(o+100)/2}%"></div></div></div>
${rel.length ? `<div class="chips">${rel.map(x => `<span class="chip rel">${x}</span>`).join('')}</div>` : ''}
${theirAllies.length ? `<p class="hint">Sworn to: ${theirAllies.join(', ')}</p>` : ''}
<h4>Diplomacy</h4>
<div id="actions">${ACTIONS.map(a => {
const reason = why(a.id, t);
if (reason === 'hide') return '';
return `<button class="abtn" data-act="${a.id}" data-t="${t}" ${reason ? 'disabled' : ''}>
<span>${a.n}${a.g ? ' Β· ' + a.g + ' gold' : ''}</span><small>${reason || a.tip}</small></button>`;
}).join('')}</div>`;
}
function render(){
if (S.p === null) return;
const me = S.realms[S.p];
$('date').textContent = SEASONS[S.season] + ' ' + S.year;
$('gold').textContent = S.gold;
$('prest').textContent = S.prestige;
$('power').textContent = Math.round(100 * me.power / totalPower()) + '%';
$('myrealm').textContent = title(me.ruler) + ' ' + me.ruler.name + ' of ' + me.name;
for (const r of S.realms){
const e = els[r.id];
const owner = r.alive ? r : S.realms[r.absorbedBy];
e.poly.setAttribute('fill', owner.color);
e.poly.classList.toggle('sel', S.sel === r.id);
e.poly.classList.toggle('dead', !r.alive);
let tag = '';
if (r.id === S.p) tag = ' πŸ‘‘';
else if (r.alive){
if (warBetween(S.p, r.id)) tag = ' βš”';
else if (allied(S.p, r.id)) tag = ' 🀝';
else if (tied(S.p, r.id)) tag = ' πŸ’';
}
e.name.textContent = r.name + tag;
e.pow.textContent = r.alive ? r.power : '';
e.name.classList.toggle('dead', !r.alive);
}
$('log').innerHTML = S.log.map(l => `<div>${l}</div>`).join('');
renderPanel();
}
/* ============ start screen & boot ============ */
function showStart(){
const body = `<p class="mint">Eldervale has no High Sovereign. Ten crowns scheme, feast, marry and murder for the honor.
Win by holding <b>half the land's power</b> β€” or by making an <b>ally of every living crown</b>.
Mind your heirs: a dynasty without one dies with you.</p>
<div id="pickgrid">${S.realms.map(r => { const c = r.ruler;
return `<div class="pickcard" data-pick="${r.id}" tabindex="0">
<span class="dot" style="background:${r.color}"></span><b>${r.name}</b>
<small>${c.race} Β· ${title(c)} ${c.name} ${c.dyn}, ${c.age}</small>
<small>Diplomacy ${c.d} Β· Intrigue ${c.i} Β· Martial ${c.m} Β· Power ${r.power}</small>
<small class="traits">${c.traits.join(' Β· ')}</small></div>`; }).join('')}</div>`;
showModal('Choose Your Crown', body, []);
document.querySelectorAll('.pickcard').forEach(el => {
const go = () => {
S.p = +el.dataset.pick; S.sel = null;
hideModal();
addLog(`You take up the crown of ${S.realms[S.p].name}. May your reign be long.`);
save(); render();
};
el.onclick = go;
el.onkeydown = ev => { if (ev.key === 'Enter' || ev.key === ' ') go(); };
});
}
$('endturn').onclick = endTurn;
$('restart').onclick = () => showModal('Begin anew?', '<p class="mint">Abandon this saga and start a new one?</p>',
[{label:'Yes β€” begin a new saga', fn:restart}, {label:'No β€” continue', fn:null}]);
$('panel').addEventListener('click', e => {
const b = e.target.closest('[data-act]');
if (b && !b.disabled) act(b.dataset.act, +b.dataset.t);
});
const hasSave = load();
buildMap();
if (hasSave && S.p !== null && !S.over) render();
else { newGame(); showStart(); }
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment