New claims: open another claim once one is paid off, travel between them (t3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
// Shared player state; the world save remains a complete standalone fallback.
|
||||
let career = null, claimBusy = false;
|
||||
function careerState() {
|
||||
const carriedBag = Array.from(bag);
|
||||
for (const f of flyers) if (f.bag) carriedBag[f.m]++;
|
||||
return { money, earned, bag: carriedBag, player: { ...player }, seen: Array.from(seen), ledger: { ...ledger }, stats: { ...stats } };
|
||||
}
|
||||
function validCareer(c, id, worldId) {
|
||||
const obj = v => v && typeof v === 'object' && !Array.isArray(v);
|
||||
const count = v => Number.isFinite(v) && v >= 0;
|
||||
const counts = v => obj(v) && Object.values(v).every(count);
|
||||
return obj(c) && c.id === id && Array.isArray(c.claims) && c.claims.length > 0 &&
|
||||
c.claims.every(v => typeof v === 'string' && v.length > 0) && new Set(c.claims).size === c.claims.length && c.claims.includes(worldId) &&
|
||||
Number.isSafeInteger(c.claimNo) && c.claimNo >= c.claims.length && count(c.money) && count(c.earned) &&
|
||||
Array.isArray(c.bag) && c.bag.length > 0 && c.bag.every(v => Number.isInteger(v) && v >= 0 && v <= 2147483647) &&
|
||||
Array.isArray(c.seen) && c.seen.length > 0 && c.seen.every(v => v === 0 || v === 1) &&
|
||||
obj(c.player) && Number.isInteger(c.player.bitTier) && !!BIT_TIERS[c.player.bitTier] &&
|
||||
Number.isInteger(c.player.tankTier) && !!TANK_TIERS[c.player.tankTier] &&
|
||||
typeof c.player.lens === 'boolean' && typeof c.player.flask === 'boolean' &&
|
||||
counts(c.ledger) && counts(c.stats) && ['dug', 'moved', 'placed', 'ignitions', 'cast', 'bars'].every(k => count(c.stats[k]));
|
||||
}
|
||||
function applyCareerState(c) {
|
||||
money = c.money; earned = c.earned;
|
||||
bag = new Int32Array(NMAT); bag.set(c.bag.slice(0, NMAT));
|
||||
bagTotal = bag.reduce((n, v) => n + v, 0);
|
||||
// These grains are already included in the shared bag, even on an older claim.
|
||||
flyers = flyers.filter(f => !f.bag);
|
||||
for (const k of Object.keys(player)) player[k] = c.player[k];
|
||||
seen = new Uint8Array(NMAT); seen.set(c.seen.slice(0, NMAT));
|
||||
ledger = { ...c.ledger };
|
||||
for (const k of Object.keys(stats)) delete stats[k];
|
||||
Object.assign(stats, c.stats);
|
||||
}
|
||||
async function loadCareer() {
|
||||
career = null; GAME.careerId = ''; GAME.claimNo = 1;
|
||||
try {
|
||||
if (!GAME.worldId) GAME.worldId = newWorldId();
|
||||
const entry = (await listWorlds()).find(w => w.id === GAME.worldId);
|
||||
const c = entry && entry.career ? await DB.get('career:' + entry.career) : null;
|
||||
if (entry && validCareer(c, entry.career, GAME.worldId)) {
|
||||
career = c; applyCareerState(c); GAME.claimNo = Number.isSafeInteger(entry.claimNo) && entry.claimNo > 0 ? entry.claimNo : 1;
|
||||
} else {
|
||||
// Preserve surviving claim membership when repairing a missing or damaged record.
|
||||
const claims = entry && entry.career ? (await listWorlds()).filter(w => w.career === entry.career) : [];
|
||||
career = { id: entry && entry.career || newWorldId(), claims: claims.length ? claims.map(w => w.id) : [GAME.worldId],
|
||||
claimNo: Math.max(1, ...claims.map(w => Number.isSafeInteger(w.claimNo) && w.claimNo > 0 ? w.claimNo : 1)), ...careerState() };
|
||||
GAME.claimNo = entry && Number.isSafeInteger(entry.claimNo) && entry.claimNo > 0 ? entry.claimNo : 1;
|
||||
await worldWrite(async () => {
|
||||
const list = await listWorlds();
|
||||
let w = list.find(w => w.id === GAME.worldId);
|
||||
if (!w) { w = { id: GAME.worldId, name: GAME.worldName || 'World', seed: worldSeed, w: W, h: H, savedAt: 0 }; list.push(w); }
|
||||
Object.assign(w, { career: career.id, claimNo: GAME.claimNo, debt });
|
||||
await DB.op('readwrite', st => { st.put(career, 'career:' + career.id); st.put(list, 'worlds'); });
|
||||
});
|
||||
}
|
||||
GAME.careerId = career.id;
|
||||
} catch (e) { career = null; toast('Career could not be loaded. Using this world’s own money and bag.'); }
|
||||
}
|
||||
async function openNewClaim() {
|
||||
if (claimBusy || !career || debt > 0 || money < 5000) return false;
|
||||
claimBusy = true; const paused = GAME.paused; GAME.paused = true;
|
||||
let previous;
|
||||
try {
|
||||
if (!await saveWorld('main', true)) throw new Error('Save failed');
|
||||
previous = { save: serializeWorld(), id: GAME.worldId, name: GAME.worldName, time: GAME.playTime, career, claimNo: GAME.claimNo };
|
||||
const carried = careerState(), n = career.claimNo + 1, w = W, h = H;
|
||||
carried.money -= 5000;
|
||||
let seed; do { seed = Math.floor(Math.random() * 900000) + 100000; } while (seed === worldSeed);
|
||||
await setupNewWorld(seed, w, h, `Claim ${n}`, carried);
|
||||
debt = CLAIM_DEBT * 2 ** (n - 1);
|
||||
career = { ...career, claims: [...career.claims, GAME.worldId], claimNo: n };
|
||||
GAME.claimNo = n;
|
||||
if (!await saveWorld('auto', true)) throw new Error('Save failed');
|
||||
showLoading(false); closeAllModals(); enterGame();
|
||||
showCard({ kind: 'New claim', title: `Claim ${n}`, body: [`You owe ${fmtMoney(debt)} on this claim. Your money, bag, upgrades and what you’ve learned came along. Buildings stayed on the old claim.`], hold: 1.8 });
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (previous) {
|
||||
deserializeWorld(previous.save.meta, previous.save.buffers);
|
||||
GAME.worldId = previous.id; GAME.worldName = previous.name; GAME.playTime = previous.time;
|
||||
career = previous.career; GAME.claimNo = previous.claimNo;
|
||||
}
|
||||
showLoading(false); GAME.paused = paused; toast('Could not open a claim. Your old claim has been kept.'); return false;
|
||||
} finally { claimBusy = false; }
|
||||
}
|
||||
async function travel(worldId) {
|
||||
if (claimBusy || !career || worldId === GAME.worldId || !career.claims.includes(worldId)) return false;
|
||||
claimBusy = true; const paused = GAME.paused; let arrived = false; GAME.paused = true;
|
||||
try {
|
||||
const entry = (await listWorlds()).find(w => w.id === worldId);
|
||||
if (!entry || entry.career !== GAME.careerId) return false;
|
||||
if (!await saveWorld('main', true)) throw new Error('Save failed');
|
||||
const sv = await worldNewest(worldId);
|
||||
if (!sv || !await startFromSave(sv)) return false;
|
||||
closeAllModals(); arrived = true; return true;
|
||||
} catch (e) { toast('Could not travel. Check browser storage and try again.'); return false; }
|
||||
finally { claimBusy = false; if (!arrived) GAME.paused = paused; }
|
||||
}
|
||||
async function renderClaimsTab(body) {
|
||||
body.innerHTML = '<p class=note>Loading claims…</p>';
|
||||
try {
|
||||
const list = (await listWorlds()).filter(w => career && w.career === GAME.careerId && career.claims.includes(w.id));
|
||||
if (nbTab !== 'Claims') return;
|
||||
body.innerHTML = list.map(w => `<div class="world-row"><div class="world-info"><strong>${esc(w.name)}${w.id === GAME.worldId ? ' · here' : ''}</strong><div class="note">${esc(w.w)}×${esc(w.h)} grains · ${(w.id === GAME.worldId ? debt : w.debt) <= 0 ? 'paid off ✓' : fmtMoney(w.id === GAME.worldId ? debt : w.debt || 0) + ' owed'} · Last played: ${w.savedAt ? esc(new Date(w.savedAt).toLocaleString()) : 'Just opened'}</div></div>${w.id === GAME.worldId ? '' : `<button class="btn" data-travel="${esc(w.id)}">Travel</button>`}</div>`).join('') +
|
||||
(debt <= 0 ? `<button class="btn primary" id="claim-open" ${money < 5000 || !career ? 'disabled' : ''}>Open a new claim: $5,000 down, then ${fmtMoney(CLAIM_DEBT * 2 ** (career ? career.claimNo : 1))} owed</button>${money < 5000 ? '<p class=note>You need $5,000 for the down payment.</p>' : ''}` : '<p class=note>A new claim opens once this one is paid off.</p>') +
|
||||
'<p class=note>Machines on other claims wait until you come back.</p>';
|
||||
const button = $('claim-open');
|
||||
if (button) button.onclick = () => dialog('Open a new claim?', `<p class="note">Pay $5,000 down and owe ${fmtMoney(CLAIM_DEBT * 2 ** career.claimNo)} on fresh land. Your buildings stay here.</p>`, [
|
||||
{ label: 'Cancel', cls: 'ghost' }, { label: 'Open claim', cls: 'primary', fn: () => { closeModal('m-dialog'); openNewClaim(); } },
|
||||
]);
|
||||
body.querySelectorAll('[data-travel]').forEach(b => b.onclick = () => dialog('Travel to this claim?', '<p class=note>Your current claim will be saved. Money, bag, upgrades and knowledge come with you.</p>', [
|
||||
{ label: 'Cancel', cls: 'ghost' }, { label: 'Travel', cls: 'primary', fn: () => { closeModal('m-dialog'); travel(b.dataset.travel); } },
|
||||
]));
|
||||
} catch (e) { if (nbTab === 'Claims') body.innerHTML = '<p class=note>Could not load claims from browser storage.</p>'; }
|
||||
}
|
||||
+2
-2
@@ -110,7 +110,7 @@ function boot(hotData) {
|
||||
const [w, h] = $('nw-size').value.split('x').map(Number);
|
||||
startNewWorld(seed, w, h, $('nw-name').value);
|
||||
};
|
||||
$('btn-continue').onclick = async () => { auInit(); playSfx('ui'); const sv = await newestSave(); if (sv) startFromSave(sv); else toast('No saved world found.'); };
|
||||
$('btn-continue').onclick = async () => { auInit(); playSfx('ui'); const sv = await newestSave(); if (sv) await startFromSave(sv); else toast('No saved world found.'); };
|
||||
$('btn-title-settings').onclick = () => { auInit(); playSfx('ui'); openSettings('Display'); };
|
||||
$('btn-title-help').onclick = () => { auInit(); playSfx('ui'); openNotebook('Controls'); };
|
||||
const touchOnly = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches;
|
||||
@@ -118,7 +118,7 @@ function boot(hotData) {
|
||||
$('title').hidden = true;
|
||||
migrateWorlds().then(() => refreshContinue()).then(async () => {
|
||||
$('title').hidden = false; if (!TITLE.raf) drawTitle();
|
||||
if (hotData && hotData.resume) { const sv = await newestSave(); if (sv) startFromSave(sv); }
|
||||
if (hotData && hotData.resume) { const sv = await newestSave(); if (sv) await startFromSave(sv); }
|
||||
}).catch(() => { $('title').hidden = false; if (!TITLE.raf) drawTitle(); });
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@ let nbTab = 'Ledger';
|
||||
function openNotebook(tab) { if (tab) nbTab = tab; renderNotebook(); openModal('m-notebook'); }
|
||||
function renderNotebook() {
|
||||
const tabs = $('nb-tabs'); tabs.innerHTML = '';
|
||||
for (const name of ['Ledger', 'Market', 'Materials', 'Pieces', 'Blueprints', 'Controls']) {
|
||||
for (const name of ['Ledger', 'Market', 'Claims', 'Materials', 'Pieces', 'Blueprints', 'Controls']) {
|
||||
const b = el('button', { role: 'tab', class: name === nbTab ? 'on' : '', 'aria-selected': name === nbTab }, name);
|
||||
b.onclick = () => { playSfx('ui'); nbTab = name; renderNotebook(); };
|
||||
tabs.appendChild(b);
|
||||
@@ -25,6 +25,8 @@ function renderNotebook() {
|
||||
body.querySelectorAll('[data-tip]').forEach(b => b.onclick = () => { playSfx('ui'); GAME.tipsOpen.add(b.dataset.tip); renderNotebook(); });
|
||||
} else if (nbTab === 'Market') {
|
||||
renderMarketTab(body);
|
||||
} else if (nbTab === 'Claims') {
|
||||
renderClaimsTab(body);
|
||||
} else if (nbTab === 'Materials') {
|
||||
let rows = '', unknown = 0;
|
||||
const list = [DIRT, LDIRT, STONE, RSTONE, BARSB, PLANK, GREENB, BARBR, CLAY, LCLAY, WOOD, LWOOD, ACORN, CHARCOAL, SNOW, ICE, COALV, COAL, CUV, CUORE, FEV, FEORE, SAND, GRAVEL, GRANITE, RGRAN, BASALT, RBAS,
|
||||
|
||||
+18
-7
@@ -49,8 +49,19 @@ async function worldNewest(id) {
|
||||
}
|
||||
function deleteWorld(id) {
|
||||
return worldWrite(async () => {
|
||||
const list = (await listWorlds()).filter(w => w.id !== id);
|
||||
await DB.op('readwrite', st => { st.delete(`${id}:main`); st.delete(`${id}:auto`); st.put(list, 'worlds'); });
|
||||
const worlds = await listWorlds(), entry = worlds.find(w => w.id === id), list = worlds.filter(w => w.id !== id);
|
||||
const stored = entry && entry.career ? await DB.get('career:' + entry.career) : null;
|
||||
const c = stored && typeof stored === 'object' && !Array.isArray(stored) ? stored : null;
|
||||
const claims = entry && entry.career ? list.filter(w => w.career === entry.career).map(w => w.id) : [];
|
||||
if (c) c.claims = claims;
|
||||
await DB.op('readwrite', st => {
|
||||
st.delete(`${id}:main`); st.delete(`${id}:auto`); st.put(list, 'worlds');
|
||||
if (entry && entry.career) {
|
||||
if (!claims.length) st.delete('career:' + entry.career);
|
||||
else if (c) st.put(c, 'career:' + entry.career);
|
||||
}
|
||||
});
|
||||
if (career && c && career.id === c.id) career.claims = c.claims;
|
||||
if (GAME.worldId === id) { GAME.worldId = ''; GAME.worldName = ''; }
|
||||
});
|
||||
}
|
||||
@@ -91,14 +102,14 @@ async function saveWorld(slot, quiet) {
|
||||
try {
|
||||
const s = serializeWorld(), savedAt = Date.now(), playTime = GAME.playTime;
|
||||
if (!GAME.worldId) { GAME.worldId = newWorldId(); GAME.worldName = `World ${(await listWorlds()).length + 1}`; }
|
||||
const id = GAME.worldId, name = GAME.worldName;
|
||||
const buffers = await packBuffers(s.buffers);
|
||||
const id = GAME.worldId, name = GAME.worldName, claimNo = GAME.claimNo, c = GAME.careerId && career ? { ...career, ...careerState() } : null;
|
||||
await worldWrite(async () => {
|
||||
const buffers = await packBuffers(s.buffers);
|
||||
const list = await listWorlds();
|
||||
let entry = list.find(w => w.id === id);
|
||||
if (!entry) { entry = { id, name, createdAt: savedAt }; list.push(entry); }
|
||||
Object.assign(entry, { savedAt, playTime, seed: s.meta.seed, w: s.meta.W, h: s.meta.H });
|
||||
await DB.op('readwrite', st => { st.put({ meta: s.meta, buffers, savedAt, playTime }, `${id}:${slot}`); st.put(list, 'worlds'); });
|
||||
Object.assign(entry, { savedAt, playTime, seed: s.meta.seed, w: s.meta.W, h: s.meta.H, debt: s.meta.economy.debt, ...(c ? { career: c.id, claimNo } : {}) });
|
||||
await DB.op('readwrite', st => { st.put({ meta: s.meta, buffers, savedAt, playTime }, `${id}:${slot}`); st.put(list, 'worlds'); if (c) st.put(c, 'career:' + c.id); });
|
||||
});
|
||||
if (!quiet) toast(slot === 'auto' ? 'Autosaved.' : 'World saved.');
|
||||
return true;
|
||||
@@ -178,7 +189,7 @@ async function importWorldFile(file) {
|
||||
bufs[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT);
|
||||
}
|
||||
const id = newWorldId(), name = `Imported ${obj.meta.seed}`.slice(0, 40);
|
||||
if (!startFromSave({ meta: obj.meta, buffers: bufs, worldId: id, worldName: name })) return;
|
||||
if (!await startFromSave({ meta: obj.meta, buffers: bufs, worldId: id, worldName: name })) return;
|
||||
if (!await saveWorld('auto')) return;
|
||||
toast('World imported.');
|
||||
} catch (e) { toast('That file could not be loaded: ' + (e.message || 'unknown error')); }
|
||||
|
||||
+27
-19
@@ -15,26 +15,33 @@ function enterGame() {
|
||||
GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = '';
|
||||
hudDirty = true; renderHotbar();
|
||||
}
|
||||
function startNewWorld(seed, w, h, name = '') {
|
||||
auInit();
|
||||
showLoading(true, 0);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const worlds = await listWorlds();
|
||||
GAME.worldId = newWorldId(); GAME.worldName = cleanWorldName(name, `World ${worlds.length + 1}`);
|
||||
await putWorldEntry({ id: GAME.worldId, name: GAME.worldName, seed, w, h, createdAt: Date.now(), savedAt: 0, playTime: 0 });
|
||||
generateWorld(seed, w, h, null);
|
||||
GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.blow.clear(); GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0;
|
||||
showLoading(false);
|
||||
enterGame();
|
||||
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 });
|
||||
await saveWorld('auto');
|
||||
} catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); }
|
||||
}, 30);
|
||||
async function setupNewWorld(seed, w, h, name, carried) {
|
||||
auInit(); showLoading(true, 0);
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
for (const k of Object.keys(stats)) stats[k] = 0;
|
||||
generateWorld(seed, w, h, null);
|
||||
GAME.worldId = newWorldId(); GAME.worldName = name;
|
||||
GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.blow.clear(); GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0;
|
||||
if (carried) applyCareerState(carried);
|
||||
}
|
||||
function startFromSave(sv) {
|
||||
async function startNewWorld(seed, w, h, name = '') {
|
||||
try {
|
||||
const worlds = await listWorlds();
|
||||
await setupNewWorld(seed, w, h, cleanWorldName(name, `World ${worlds.length + 1}`));
|
||||
career = { id: newWorldId(), claims: [GAME.worldId], claimNo: 1 };
|
||||
GAME.careerId = career.id; GAME.claimNo = 1;
|
||||
await saveWorld('auto');
|
||||
showLoading(false); closeAllModals(); enterGame();
|
||||
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 });
|
||||
} catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); }
|
||||
}
|
||||
async function startFromSave(sv) {
|
||||
auInit();
|
||||
try { deserializeWorld(sv.meta, sv.buffers); }
|
||||
GAME.paused = true;
|
||||
try {
|
||||
for (const k of Object.keys(stats)) stats[k] = 0;
|
||||
deserializeWorld(sv.meta, sv.buffers);
|
||||
}
|
||||
catch (e) {
|
||||
toast(e && e.message ? e.message : 'That save could not be loaded.', { big: true, title: 'Save not loaded' });
|
||||
GAME.running = false; $('hud').hidden = true; $('title').hidden = false; if (!TITLE.raf) drawTitle();
|
||||
@@ -43,6 +50,7 @@ function startFromSave(sv) {
|
||||
GAME.worldId = sv.worldId || ''; GAME.worldName = sv.worldName || '';
|
||||
GAME.playTime = sv.playTime || 0;
|
||||
GAME.drag = null; GAME.blow.clear();
|
||||
await loadCareer();
|
||||
enterGame();
|
||||
return true;
|
||||
}
|
||||
@@ -111,7 +119,7 @@ async function renderWorlds(editId) {
|
||||
button('Save', async () => { await renameWorld(w.id, input.value); await renderWorlds(); await refreshContinue(); });
|
||||
button('Cancel', () => renderWorlds());
|
||||
} else {
|
||||
button('Load', async () => { const sv = await worldNewest(w.id); if (sv && startFromSave(sv)) closeAllModals(); else if (!sv) toast('No saved world found.'); });
|
||||
button('Load', async () => { const sv = await worldNewest(w.id); if (sv && await startFromSave(sv)) closeAllModals(); else if (!sv) toast('No saved world found.'); });
|
||||
button('Rename', () => renderWorlds(w.id));
|
||||
button('Delete', () => dialog('Delete world?', `<p class="note">Delete ${esc(w.name)} and its autosave? Exported world files are not affected.</p>`, [
|
||||
{ label: 'Cancel', cls: 'ghost' },
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ const GAME = {
|
||||
brush: [3, 2, 0, 0, 0], tipsOpen: new Set(), cards: [], vacFilter: 0, rmbConsumed: false, rmbSeen: new Set(), lookHold: { x: 0, y: 0 },
|
||||
paste: false, selDrag: null, rmbRec: null, dragDir: -1, replaceShown: false, clip: null, drag: null, aim: { x: 0, y: 0 }, mouse: { x: 0, y: 0, in: false },
|
||||
lmb: false, rmb: false, drillW: 1, drillTune: 0, lastDug: 0, padRun: 0, status: '', statusKind: 'err', statusT: 0, usingPad: false, padAim: { x: 12, y: 0 },
|
||||
lastAutosave: 0, playTime: 0, lastHud: 0, worldId: '', worldName: '',
|
||||
lastAutosave: 0, playTime: 0, lastHud: 0, worldId: '', worldName: '', careerId: '', claimNo: 1,
|
||||
};
|
||||
const keys = new Set();
|
||||
let hudDirty = true;
|
||||
|
||||
Reference in New Issue
Block a user