massfront-playtest / src /daily.js
CREATORJD's picture
Publish MASSFRONT web 1.33.33 (part 3)
3597f5a verified
Raw
History Blame Contribute Delete
15.5 kB
;
;
/* ============================================================================
DAILY ORDERS & BOOSTERS
----------------------------------------------------------------------------
Two systems that only make sense together: orders are a reason to open the
game today, and boosters are what an order pays out with. A currency reward
alone buys a percentage in a store; a booster changes the next hour of play,
which is a better prize and a better hook.
Orders are drawn from the day's date rather than stored, so the same three
appear on every device the player owns and nobody can reroll by reinstalling.
Progress is tracked against what actually happened in a match, never against
a button press. Orders are also flavoured — tied to a faction, a map, a
mission type or a hazard — using globals the match itself already leaves
behind (AI.fac, curMap, goalSel, the active wildcards) rather than anything
invented for this file. A win is still a win either way; the order just
asks which kind.
Boosters are timed multipliers, not consumable charges. Buying an hour means
an hour of wall clock — a player who has to stop after ten minutes has lost
fifty of them, which is exactly why the durations are offered as a choice.
============================================================================ */
const BOOSTS={
xp :{nm:'FIELD PROMOTION', em:'⬆', ds:'+60% career XP', mul:1.60},
cores:{nm:'SALVAGE RIGHTS', em:'⬡', ds:'+60% cores earned', mul:1.60},
res :{nm:'PRIORITY SUPPLY', em:'⛏', ds:'+20% mass and energy', mul:1.20},
build:{nm:'FORCED TEMPO', em:'⚙', ds:'+25% build speed', mul:1.25},
};
const BOOST_DUR=[
{k:'30m', s:1800, nm:'30 MIN'},
{k:'1h', s:3600, nm:'1 HOUR'},
{k:'24h', s:86400, nm:'24 HOURS'},
];
function boostState(){ META.boosts=META.boosts||{}; return META.boosts; }
function boostLeft(k){
const b=boostState()[k];
return b? Math.max(0,(b-Date.now())/1000) : 0;
}
function boostActive(k){ return boostLeft(k)>0; }
/* Granting an active booster EXTENDS it. Overwriting would mean a 24-hour
reward could shorten a 24-hour booster the player already had. */
function grantBoost(k,secs){
const b=boostState();
const base=Math.max(Date.now(), b[k]||0);
b[k]=base+secs*1000;
metaSave(); renderBoosts();
}
function boostMul(k){ return boostActive(k)? BOOSTS[k].mul : 1; }
function fmtLeft(s){
s=Math.max(0,s|0);
if(s>=3600) return ((s/3600)|0)+'h '+(((s%3600)/60)|0)+'m';
if(s>=60) return ((s/60)|0)+'m';
return s+'s';
}
function renderBoosts(){
const el=document.getElementById('boostRow'); if(!el) return;
const on=Object.keys(BOOSTS).filter(boostActive);
el.style.display=on.length?'flex':'none';
el.innerHTML=on.map(k=>'<div class="boostChip">'
+(typeof itemArt==='function'?itemArt('bst_'+k,BOOSTS[k].em,18):'<span>'+BOOSTS[k].em+'</span>')
+BOOSTS[k].nm+' <b>'+fmtLeft(boostLeft(k))+'</b></div>').join('');
}
/* ---- ORDERS ---------------------------------------------------------------- */
/* Deterministic from the date: the same day gives the same three orders on
every device, and reinstalling does not reroll them. */
function dayKey(d){
d=d||new Date();
return d.getFullYear()*10000+(d.getMonth()+1)*100+d.getDate();
}
function dayRand(seed){
let s=seed>>>0;
return ()=>{ s=(s*1664525+1013904223)>>>0; return s/4294967296; };
}
/* A rotating one-line SITREP above the order list — no mechanical weight,
just a reminder that the war in src/story.js keeps moving on days you don't
rank up. Deterministic per day like everything else here. */
const SITREPS=[
'Hive density rising across all four systems.',
'Ascendancy patrols report contact on every front.',
'Coalition trade routes rerouted around Vespera Caldera.',
'Brood hive count unconfirmed — and climbing.',
'Joint operations still unauthorised past squad level.',
"Vex's broadcasts are back on the open band.",
"Renn's invoices outpace Command's replies.",
'Four homeworlds: three banners, one infestation.',
'Nordhall Frontline Shelf reports overlapping claims. Again.',
'Doctrine reminder: hold what you take, take it clean.',
];
function dailySitrep(){ return SITREPS[dayKey()%SITREPS.length]; }
const ORDERS=[
/* ---- general conduct of the war — always achievable through normal play */
{id:'win1', nm:'Take the field', ds:'Win 1 match', goal:1, stat:'wins', rw:{cores:120, boost:['xp','30m']}},
{id:'win3', nm:'Sustained offensive', ds:'Win 3 matches', goal:3, stat:'wins', rw:{cores:320, boost:['cores','1h']}},
{id:'play2', nm:'Deployment orders', ds:'Play 2 matches', goal:2, stat:'played', rw:{cores:90, boost:['res','30m']}},
{id:'kill400',nm:'Attrition', ds:'Destroy 400 units', goal:400, stat:'kills', rw:{cores:150, boost:['res','1h']}},
{id:'kill1500',nm:'Grinder', ds:'Destroy 1500 units', goal:1500,stat:'kills', rw:{cores:380, boost:['xp','1h']}},
{id:'build12',nm:'Engineering corps', ds:'Raise 12 structures', goal:12, stat:'built', rw:{cores:110, boost:['build','30m']}},
{id:'hard1', nm:'Against the odds', ds:'Win once on Hard', goal:1, stat:'hardWins', rw:{cores:400, boost:['cores','24h']}},
{id:'wild1', nm:'Calculated risk', ds:'Win a match with a wildcard active', goal:1, stat:'wcWins', rw:{cores:260, boost:['xp','1h']}},
{id:'purge', nm:'Sterilisation', ds:'Destroy 2 hives', goal:2, stat:'nests', rw:{cores:200, boost:['res','1h']}},
{id:'fast', nm:'Blitz', ds:'Win in under 8 minutes', goal:1, stat:'fastWins', rw:{cores:300, boost:['build','1h']}},
/* ---- named fronts — tied to a specific faction, same rivalry story.js tells */
{id:'vsLegion', nm:'Ascendancy front', ds:'Beat the Red Ascendancy', goal:1, stat:'winLegion', rw:{cores:190, boost:['xp','30m']}},
{id:'vsSyndicate', nm:'Coalition front', ds:'Beat the Syndicate Coalition', goal:1, stat:'winSyndicate', rw:{cores:190, boost:['res','30m']}},
{id:'vsHorde', nm:'Hive front', ds:'Beat the Umbral Brood', goal:1, stat:'winHorde', rw:{cores:190, boost:['build','30m']}},
{id:'vsNova', nm:'Frontline front', ds:'Beat Terran Frontline Command', goal:1, stat:'winNova', rw:{cores:190, boost:['xp','30m']}},
/* ---- held ground — first site of each homeworld, so a contract is completable
the day that planet opens rather than asking for a locked later region. ---- */
{id:'mapVanguard', nm:'Hold Parade Circumference', ds:'Win on Aelos Parade Circumference', goal:1, stat:'map_aelos_north_small', rw:{cores:150, boost:['xp','30m']}},
{id:'mapHighland', nm:'Hold Frostwake Grid', ds:'Win on Nordhall Frostwake Docks', goal:1, stat:'map_nordhall_isles_small', rw:{cores:150, boost:['res','30m']}},
{id:'mapIsles', nm:'Hold Buried Court', ds:'Win on Pyraeth Crown Pit', goal:1, stat:'map_pyraeth_crater_small', rw:{cores:150, boost:['build','30m']}},
{id:'mapCrater', nm:'Hold Caldera Nests', ds:'Win on Vespera Hatch Pit', goal:1, stat:'map_vespera_spire_small', rw:{cores:150, boost:['cores','30m']}},
/* ---- mission type — tied to the victory condition chosen at setup */
{id:'goalDom', nm:'Domination order', ds:'Win a Domination match', goal:1, stat:'goal_domination', rw:{cores:230, boost:['xp','1h']}},
{id:'goalPurge', nm:'Full Purge', ds:'Win a Hive Purge mission', goal:1, stat:'goal_purge', rw:{cores:230, boost:['res','1h']}},
{id:'goalSurv', nm:'Last Stand order', ds:'Win a Last Stand match', goal:1, stat:'goal_survival', rw:{cores:230, boost:['build','1h']}},
/* ---- named hazards — the harder, opt-in modifiers from Operations */
{id:'wcMoon', nm:'Blood Moon detail', ds:'Win a match during a Blood Moon', goal:1, stat:'wc_moon', rw:{cores:280, boost:['xp','1h']}},
{id:'wcTitan', nm:'Titan Rush detail', ds:'Win a match with Titan Rush active', goal:1, stat:'wc_titan', rw:{cores:320, boost:['cores','1h']}},
{id:'wcWild', nm:'Rampant Wildlife detail',ds:'Win a match with Rampant Wildlife active', goal:1, stat:'wc_wild', rw:{cores:320, boost:['build','1h']}},
];
function dailyState(){
META.daily=META.daily||{day:0,prog:{},claimed:{},streak:0,lastDay:0};
const d=dayKey();
if(META.daily.day!==d){
/* A new day. The streak survives only if yesterday was played; anything
longer than that resets it, which is what makes a streak mean something. */
const yest=dayKey(new Date(Date.now()-86400000));
META.daily.streak=(META.daily.lastDay===yest||META.daily.lastDay===d)?(META.daily.streak||0):0;
META.daily={day:d, prog:{}, claimed:{}, streak:META.daily.streak||0, lastDay:META.daily.lastDay||0};
metaSave();
}
return META.daily;
}
function todaysOrders(){
const d=dailyState().day;
const r=dayRand(d);
const pool=ORDERS.slice();
const out=[];
while(out.length<3&&pool.length) out.push(pool.splice((r()*pool.length)|0,1)[0]);
return out;
}
/* Called once at the end of every match with what actually happened. Runs
synchronously from metaGrant(), before newSkirmish() touches anything, so
the match globals below (AI.fac, curMap, goalSel, WC) still describe the
match that just ended — no separate hook into main.js/meta.js needed. */
function dailyRecord(res){
const st=dailyState();
const add=(k,v)=>{ if(v>0) st.prog[k]=(st.prog[k]||0)+v; };
add('played',1);
add('kills',res.kills|0);
add('built',res.built|0);
add('nests',res.nests|0);
if(res.win){
add('wins',1);
if(res.difficulty>=2) add('hardWins',1);
if(res.wildcards>0) add('wcWins',1);
if(res.seconds>0&&res.seconds<480) add('fastWins',1);
const fac=(typeof AI!=='undefined'&&AI.fac)||'';
if(fac==='legion') add('winLegion',1);
else if(fac==='syndicate') add('winSyndicate',1);
else if(fac==='horde') add('winHorde',1);
else if(fac==='nova') add('winNova',1);
const map=(typeof curMap!=='undefined'&&curMap)||'';
if(map) add('map_'+map,1);
const goal=(typeof goalSel!=='undefined'&&goalSel)||'';
if(goal) add('goal_'+goal,1);
const wc=(typeof WC!=='undefined'&&WC)||{};
/* OPMODS renamed Blood Moon to `dark` and Hiveworld to `swarm`; the
orders still ask for the live wildcard tokens (`moon`, `wild`) that
random picks and the sim actually set. Count both names or those
three flavour orders can never complete. */
const alias={dark:'moon',moon:'moon',swarm:'wild',wild:'wild',titan:'titan'};
for(const id in wc) if(wc[id]){
add('wc_'+id,1);
if(alias[id]&&alias[id]!==id) add('wc_'+alias[id],1);
}
st.lastDay=st.day;
if(!st.countedToday){ st.countedToday=1; st.streak=(st.streak||0)+1; }
}
/* The match earned these. Leaving them sitting on CLAIM meant a player
who never opened Daily Orders was paid nothing for finishing them. */
const granted=dailyGrantReady(true);
metaSave();
renderDaily();
DAILY_LAST={granted,ready:todaysOrders().filter(o=>orderDone(o)&&!orderClaimed(o))};
}
let DAILY_LAST={granted:[],ready:[]};
function dailyLast(){ return DAILY_LAST; }
function dailyGrantReady(quiet){
const granted=[];
for(const o of todaysOrders()){
if(orderDone(o)&&!orderClaimed(o)){
claimOrder(o,quiet);
granted.push(o);
}
}
return granted;
}
function orderDone(o){ return (dailyState().prog[o.stat]||0)>=o.goal; }
function orderClaimed(o){ return !!dailyState().claimed[o.id]; }
function claimOrder(o,quiet){
if(!orderDone(o)||orderClaimed(o)) return;
const st=dailyState();
st.claimed[o.id]=1;
/* The streak bonus rides on the reward rather than being its own currency:
one number to understand, and it grows where the player already looks. */
const sb=1+Math.min(0.5,(st.streak||0)*0.05);
const cores=Math.round(o.rw.cores*sb);
META.cores+=cores;
if(o.rw.boost){
const [k,dk]=o.rw.boost;
const dur=BOOST_DUR.find(d=>d.k===dk);
grantBoost(k,dur?dur.s:1800);
if(!quiet) toast('✓ '+cores+' cores · '+BOOSTS[k].em+' '+BOOSTS[k].nm+' for '+(dur?dur.nm.toLowerCase():'30 min'));
} else if(!quiet) toast('✓ '+cores+' cores');
metaSave(); renderMetaHead(); renderDaily();
if(!quiet){ sfx('level'); buzz(25); }
}
/* The menu badge: a dot only when something is actually claimable, so it is a
signal rather than decoration. */
function renderDailyDot(){
const dot=document.getElementById('dailyDot'); if(!dot) return;
const any=todaysOrders().some(o=>orderDone(o)&&!orderClaimed(o));
dot.classList.toggle('on',any);
}
function renderDaily(){
renderDailyDot();
const b2=document.getElementById('boostRow2');
if(b2){
const on=Object.keys(BOOSTS).filter(boostActive);
b2.innerHTML=on.length? on.map(k=>'<div class="boostChip">'
+(typeof itemArt==='function'?itemArt('bst_'+k,BOOSTS[k].em,18):'<span>'+BOOSTS[k].em+'</span>')
+BOOSTS[k].nm+' <b>'+fmtLeft(boostLeft(k))+'</b></div>').join('')
: '<div style="font-size:11px;color:#7fa8c6;font-weight:600">None active — complete an order to earn one</div>';
}
const g=document.getElementById('dailyList'); if(!g) return;
const st=dailyState();
const hd=document.getElementById('dailyHead');
if(hd){
const ms=new Date(); ms.setHours(24,0,0,0);
hd.innerHTML='<div style="font-size:11px;color:#7fa8c6;font-weight:700;letter-spacing:.02em;margin-bottom:5px">🛰 SITREP — '+dailySitrep()+'</div>'
+'<div>Resets in '+fmtLeft((ms-Date.now())/1000)
+(st.streak?' · 🔥 '+st.streak+'-day streak (+'+Math.round(Math.min(50,st.streak*5))+'%)':'')+'</div>';
}
let h='';
for(const o of todaysOrders()){
const p=Math.min(o.goal,st.prog[o.stat]||0);
const done=p>=o.goal, claimed=orderClaimed(o);
const bk=o.rw.boost?o.rw.boost[0]:null;
h+='<div class="ordItem'+(claimed?' claimed':done?' done':'')+'" data-id="'+o.id+'">'
+'<div class="ordTx"><b>'+o.nm+'</b><span>'+o.ds+'</span>'
+'<div class="ordBarO"><div class="ordBarF" style="width:'+(p/o.goal*100)+'%"></div></div>'
+'<div class="ordRw">⬡ '+o.rw.cores+(bk?' · '+
(typeof itemArt==='function'?itemArt('bst_'+bk,BOOSTS[bk].em,14):BOOSTS[bk].em)+' '+BOOSTS[bk].nm+' '+
(BOOST_DUR.find(d=>d.k===o.rw.boost[1])||{nm:''}).nm:'')+'</div></div>'
+'<div class="ordAct">'+(claimed?'✓':done?'CLAIM':p+'/'+o.goal)+'</div></div>';
}
g.innerHTML=h;
g.querySelectorAll('.ordItem').forEach(el=>{
const claim=()=>{
const o=todaysOrders().find(x=>x.id===el.dataset.id);
if(o) claimOrder(o);
};
if(typeof mfBindTap==='function') mfBindTap(el,claim); else el.addEventListener('click',claim);
});
renderBoosts();
if(typeof mfBindTabs==='function') mfBindTabs(document.getElementById('dailyScr'),'orders');
}
function initDaily(){
dailyState();
renderDaily(); renderBoosts();
/* The countdown and the booster clocks tick once a second — often enough to
feel live, rare enough to be free. */
setInterval(()=>{
const scr=document.getElementById('dailyScr');
if(scr&&scr.style.display==='flex') renderDaily();
renderBoosts(); renderDailyDot();
},1000);
}