Complete map refresh guards and fix the dashboard job embed

This commit is contained in:
2026-10-06 12:18:22 +05:00
parent 923abdc472
commit 5f43e97d32
11 changed files with 127 additions and 27 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ node_modules/
.data/ .data/
artifacts/ artifacts/
*.log *.log
*.hprof
**/.gradle/ **/.gradle/
**/build/ **/build/
playwright-report/ playwright-report/

View File

@@ -35,7 +35,7 @@ Missing vanilla assets are also prepared automatically when a plugin export is r
## Install the capture tools ## Install the capture tools
Maps support up to 24 locations and games choose five at random. Owners can opt into player contributions, with a cumulative 12-panorama limit per player and owner approval followed by moderator review. See [panorama contributions](docs/panorama-contributions.md) for both tool workflows and deployment instructions. Maps support up to 24 locations and games choose five at random. Owners can opt into player contributions, with a cumulative 12-panorama limit per guest player and owner approval followed by moderator review. See [panorama contributions](docs/panorama-contributions.md) for both tool workflows and deployment instructions.
Contributions check the server address recorded during capture. Owners can correct or clear the map's expected address. Fabric records the connected server automatically; plugin admins must set `server-address` or run `/minegeo server <hostname[:port]>` before exporting. Contributions check the server address recorded during capture. Owners can correct or clear the map's expected address. Fabric records the connected server automatically; plugin admins must set `server-address` or run `/minegeo server <hostname[:port]>` before exporting.

View File

@@ -136,7 +136,7 @@ export function DraftEditor({ id }: { id: string }) {
<div className="editor-navigation"> <div className="editor-navigation">
<Link href="/dashboard">← Your maps</Link> <Link href="/dashboard">← Your maps</Link>
<a href="#locations">Choose locations</a> <a href="#locations">Choose locations</a>
<a href="#map-tools">Map tools</a> {!draft.parent_dataset_id && <a href="#map-tools">Map tools</a>}
</div> </div>
<span className="badge">{draft.state}</span> <span className="badge">{draft.state}</span>
{draft.update_state && ( {draft.update_state && (
@@ -299,18 +299,20 @@ export function DraftEditor({ id }: { id: string }) {
</button> </button>
</div> </div>
)} )}
<div id="map-tools"> {!draft.parent_dataset_id && (
<MapRefresh <div id="map-tools">
id={id} <MapRefresh
disabled={busy || !editable || refreshing} id={id}
onQueued={() => { disabled={busy || !editable || refreshing}
setMessage( onQueued={() => {
"Map refresh queued. Keep this page open to follow progress.", setMessage(
); "Map refresh queued. Keep this page open to follow progress.",
setVersion((v) => v + 1); );
}} setVersion((v) => v + 1);
/> }}
</div> />
</div>
)}
{!draft.parent_dataset_id && ( {!draft.parent_dataset_id && (
<OwnerContributions <OwnerContributions
id={id} id={id}

View File

@@ -1,6 +1,6 @@
# More panoramas and player contributions # More panoramas and player contributions
Maps hold 5–24 captured locations. Each game chooses five distinct locations in random order. The server plugin now picks random safe surface positions, with `viewpoints: 24` as the default for new installations; existing config files keep their value until edited and reloaded. The Fabric mod captures where the player is standing and permits up to 24 local captures for a new map. Maps hold 5–24 captured locations. Each game chooses five distinct locations in random order. The server plugin now picks random safe surface positions, with `viewpoints: 24` as the default for new installations; existing config files keep their value until edited and reloaded. The Fabric mod captures where the player is standing and permits up to 24 local captures; the uploader sends as many as the map and player limits allow.
## Owner workflow ## Owner workflow
@@ -12,6 +12,10 @@ In **Player submissions**, inspect the submitted locations in 360°. Accept them
For an already published map, its approved name, description, panorama count, and revision stay live until a moderator approves the update. Rejecting an update leaves that version playable. Existing games continue using the revision they started with. Editing and accepting further submissions are locked while the update is under review. For an already published map, its approved name, description, panorama count, and revision stay live until a moderator approves the update. Rejecting an update leaves that version playable. Existing games continue using the revision they started with. Editing and accepting further submissions are locked while the update is under review.
## Refresh guessing map
**Map tools → Refresh guessing map** rebuilds only the overhead map through the worker. It consumes no panorama slots and leaves every panorama image unchanged. Choose the original capture, another processed capture you own, or an accepted contribution whose bounds and expected server address match the map exactly. To show new buildings, export and upload fresh terrain first. The published map and ongoing games keep their current image until a moderator approves the update.
## Player workflow ## Player workflow
Install the updated capture tool and link your own MineGeo account. A new map needs at least five panoramas; a contribution may contain just one. Install the updated capture tool and link your own MineGeo account. A new map needs at least five panoramas; a contribution may contain just one.
@@ -19,7 +23,7 @@ Install the updated capture tool and link your own MineGeo account. A new map ne
- **Fabric:** Press G, paste the owner's map ID into **Add to map ID**, capture within the original bounds, and upload. Leave the field blank to upload a new map. - **Fabric:** Press G, paste the owner's map ID into **Add to map ID**, capture within the original bounds, and upload. Leave the field blank to upload a new map.
- **Plugin:** Set the website and your own contributor token, select an explored area within the map bounds, export it, then run `/minegeo contribute <map ID>`. Server permission `minegeo.admin` is still required for server exports. Players without that permission can use the Fabric mod. - **Plugin:** Set the website and your own contributor token, select an explored area within the map bounds, export it, then run `/minegeo contribute <map ID>`. Server permission `minegeo.admin` is still required for server exports. Players without that permission can use the Fabric mod.
The uploader keeps up to 12 in-bounds locations, or fewer when the map/player has fewer slots available. Its saved selection and upload ledger make retries idempotent. After processing, open **Open contribution** in the dashboard, inspect/select locations, and **Submit to map owner**. You may withdraw a pending submission to release its reserved quota. A completed accepted submission cannot be edited or overwritten; use a fresh capture to add more. The uploader keeps up to the available slots (24 for the map owner, 12 per guest), or fewer when the map has less room. Its saved selection and upload ledger make retries idempotent. After processing, open **Open contribution** in the dashboard, inspect/select locations, and **Submit to map owner**. You may withdraw a pending submission to release its reserved quota. A completed accepted submission cannot be edited or overwritten; use a fresh capture to add more.
Both tools can contribute to either kind of map. The original surface map and bounds remain the basis for guessing; contributions append panoramas. Owners and moderators must check that submissions show the correct world, useful views, and acceptable visual quality. Both tools can contribute to either kind of map. The original surface map and bounds remain the basis for guessing; contributions append panoramas. Owners and moderators must check that submissions show the correct world, useful views, and acceptable visual quality.
@@ -37,6 +41,6 @@ This prevents accidental submissions from another server. Capture addresses are
## Deployment ## Deployment
The configured MineGeo Supabase project has all three October 5 migrations applied. For another database, apply `20261005063646_panorama_contributions.sql`, `20261005065029_withdraw_contribution.sql`, and `20261005073253_contribution_server_check.sql` after the existing migrations. Deploy the website and worker together. Replace both Minecraft JARs from `public/tools` and restart Minecraft/the server. An installed older tool does not acquire the new contribution controls when the website changes. The configured MineGeo Supabase project has the first four October 5 migrations applied. Apply `20261005200000_map_refresh_fixes.sql` before deploying this website/worker pair. For another database, apply `20261005063646_panorama_contributions.sql`, `20261005065029_withdraw_contribution.sql`, `20261005073253_contribution_server_check.sql`, `20261005141142_map_refresh.sql`, and `20261005200000_map_refresh_fixes.sql` after the existing migrations. Deploy the website and worker together. Replace both Minecraft JARs from `public/tools` and restart Minecraft/the server. An installed older tool does not acquire the new contribution controls when the website changes.
The new transactional functions are callable only by the server's service role. They verify map ownership, opt-in, bounds, processing validation and review state, and serialize uploads/edits/approvals using a map row lock. Media provenance is assigned by the server after acceptance; uploaders cannot choose another dataset's private media paths. Published revisions remain immutable. The new transactional functions are callable only by the server's service role. They verify map ownership, opt-in, bounds, processing validation and review state, and serialize uploads/edits/approvals using a map row lock. Media provenance is assigned by the server after acceptance; uploaders cannot choose another dataset's private media paths. Published revisions remain immutable.

View File

@@ -1,5 +1,15 @@
# Verification of the export/upload fixes # Verification of the export/upload fixes
## Map refresh completion (October 5, 2026)
The unfinished "map refresh" commit was completed. A follow-up migration (`20261005200000_map_refresh_fixes.sql`) restores the contribution server-address check in `submit_draft` that the map-refresh migration had accidentally dropped, and rejects map-refresh jobs both when queued (`refresh_map`) and completed (`complete_job`) for contribution datasets. The map page now receives the job `kind`, so refresh progress, the refreshing notice and the failure alert render; refresh controls and the Map tools anchor are hidden on contributions; map-only worker downloads report progress against the filtered asset list; the plugin help text describes the owner/guest limits.
The 743 MiB `minecraft/java_pid42664.hprof` heap dump, tracked since the first commit, was purged from all Git history with `git filter-branch`, expired reflogs and `git gc`; `*.hprof` is now ignored. The local repository's `.git` directory shrank from about 170 MiB to 1.2 MiB. The rewritten history has not been pushed; because every commit hash changed, the remote needs a force-push before further work is pulled elsewhere.
Verification at the user's request was limited to local checks: all 65 Node tests and TypeScript checking passed under Node 22, and both Minecraft JARs were rebuilt with the updated help text. `tests/sql/map-refresh.sql` now includes contribution-refresh guard cases. The new migration has not been applied to the configured Supabase project, and the SQL, plugin, renderer and browser regressions were not rerun in this pass. The user had already run the plugin regression with Codex before these fixes.
---
## Panorama contributions (October 5, 2026) ## Panorama contributions (October 5, 2026)
Maps now retain up to 24 captures. Server exports pick random safe positions, and games retain five distinct random rounds. Owners opt into contributions by map ID; non-owners have a cumulative 12-panorama quota. Contributions need owner acceptance and a subsequent moderator review of the combined map/update. Maps now retain up to 24 captures. Server exports pick random safe positions, and games retain five distinct random rounds. Owners opt into contributions by map ID; non-owners have a cumulative 12-panorama quota. Contributions need owner acceptance and a subsequent moderator review of the combined map/update.

View File

@@ -332,7 +332,7 @@ export async function handle(request: NextRequest, path: string[]) {
await client await client
.from("datasets") .from("datasets")
.select( .select(
"*, upload_jobs(state,progress,error,updated_at,lease_until,kind)", "*, upload_jobs!dataset_id(state,progress,error,updated_at,lease_until,kind)",
) )
.eq("owner_id", owner) .eq("owner_id", owner)
.order("created_at", { ascending: false }), .order("created_at", { ascending: false }),
@@ -349,6 +349,8 @@ export async function handle(request: NextRequest, path: string[]) {
} }
const d = await ownedDataset(request, path[1]); const d = await ownedDataset(request, path[1]);
if (path[2] === "refresh-map") { if (path[2] === "refresh-map") {
if (d.parent_dataset_id)
throw new Error("Only maps can refresh their guessing map");
if (method === "POST") { if (method === "POST") {
const body = z const body = z
.object({ sourceId: idSchema }) .object({ sourceId: idSchema })
@@ -424,7 +426,7 @@ export async function handle(request: NextRequest, path: string[]) {
const manifest = datasetSchema.parse(input.manifest); const manifest = datasetSchema.parse(input.manifest);
const jobResult = await client const jobResult = await client
.from("upload_jobs") .from("upload_jobs")
.select("state,progress,error,updated_at,lease_until") .select("state,progress,error,updated_at,lease_until,kind")
.eq("dataset_id", d.id) .eq("dataset_id", d.id)
.maybeSingle(); .maybeSingle();
checkServiceError(jobResult.error); checkServiceError(jobResult.error);

View File

@@ -42,7 +42,7 @@ public final class MineGeoPlugin extends JavaPlugin {
// every rejected command receives a visible explanation. // every rejected command receives a visible explanation.
if(!(sender instanceof ConsoleCommandSender||sender instanceof RemoteConsoleCommandSender||sender instanceof Player&&sender.hasPermission("minegeo.admin"))){sender.sendMessage("MineGeo: administrator permission required.");return true;} if(!(sender instanceof ConsoleCommandSender||sender instanceof RemoteConsoleCommandSender||sender instanceof Player&&sender.hasPermission("minegeo.admin"))){sender.sendMessage("MineGeo: administrator permission required.");return true;}
try { try {
if(args.length==0||args[0].equalsIgnoreCase("help")) {sender.sendMessage(new String[]{"MineGeo commands:","/minegeo website <http(s)://address:port>","/minegeo server <hostname[:port]> — capture server address","/minegeo link <dashboard token>","/minegeo area <world> <minX> <minZ> <maxX> <maxZ>","/minegeo export <map name> — saves a local export","/minegeo upload — uploads the latest completed export","/minegeo contribute <map ID> — send up to 12 panoramas to its owner","/minegeo status | cancel | reload"});return true;} if(args.length==0||args[0].equalsIgnoreCase("help")) {sender.sendMessage(new String[]{"MineGeo commands:","/minegeo website <http(s)://address:port>","/minegeo server <hostname[:port]> — capture server address","/minegeo link <dashboard token>","/minegeo area <world> <minX> <minZ> <maxX> <maxZ>","/minegeo export <map name> — saves a local export","/minegeo upload — uploads the latest completed export","/minegeo contribute <map ID> — add panoramas (24 owner, 12 guests)","/minegeo status | cancel | reload"});return true;}
switch(args[0].toLowerCase(Locale.ROOT)) { switch(args[0].toLowerCase(Locale.ROOT)) {
case "area" -> { case "area" -> {
requireIdle();if(args.length!=6)throw new IllegalArgumentException("Usage: /minegeo area <world> <minX> <minZ> <maxX> <maxZ>"); requireIdle();if(args.length!=6)throw new IllegalArgumentException("Usage: /minegeo area <world> <minX> <minZ> <maxX> <maxZ>");

View File

@@ -0,0 +1,64 @@
-- Follow-up to 20261005141142_map_refresh.sql. The original migration was
-- already applied, and its submit_draft accidentally dropped the shared
-- contribution server-address check. It also allowed map-refresh jobs on
-- contribution datasets. This migration restores and hardens all three
-- functions without changing the queue format.
create or replace function public.refresh_map(p_dataset uuid,p_owner uuid,p_source uuid) returns void
language plpgsql security invoker set search_path='' as $$
declare d public.datasets; s public.datasets;
begin
select * into d from public.datasets where id=p_dataset and owner_id=p_owner for update;
if not found then raise exception 'Map owner permission required'; end if;
if d.parent_dataset_id is not null then raise exception 'Only a map can refresh its guessing map'; end if;
if d.state not in ('draft','rejected','published') or d.update_state='review' or d.contribution_status in ('submitted','accepted','rejected') then raise exception 'Wait until the current review finishes'; end if;
if exists(select 1 from public.upload_jobs where dataset_id=d.id and state in ('queued','running')) then raise exception 'A render is already in progress'; end if;
select * into s from public.datasets where id=p_source;
if not found or not (s.owner_id=p_owner or (s.parent_dataset_id=d.id and s.contribution_status='accepted')) then raise exception 'Capture permission required'; end if;
if s.bounds<>d.bounds then raise exception 'Capture boundaries must match the map exactly'; end if;
if s.map_path is null or s.state not in ('draft','rejected','review','published') or not exists(select 1 from public.dataset_inputs where dataset_id=s.id and validated) then raise exception 'Wait for the capture to finish processing'; end if;
perform public.assert_capture_server(d.contribution_server_address, (select manifest->>'serverAddress' from public.dataset_inputs where dataset_id=s.id));
insert into public.upload_jobs(dataset_id,kind,source_dataset_id) values(d.id,'map',s.id)
on conflict(dataset_id) do update set id=gen_random_uuid(),kind='map',source_dataset_id=s.id,state='queued',attempts=0,progress=0,error=null,lease_owner=null,lease_until=null,created_at=now(),updated_at=now();
end; $$;
revoke execute on function public.refresh_map(uuid,uuid,uuid) from public,anon,authenticated;
grant execute on function public.refresh_map(uuid,uuid,uuid) to service_role;
create or replace function public.complete_job(p_job uuid,p_worker text,p_count integer,p_cover text) returns void
language plpgsql security invoker set search_path='' as $$
declare j public.upload_jobs; d public.datasets;
begin
select * into j from public.upload_jobs where id=p_job and lease_owner=p_worker and state='running' and lease_until>now() for update;
if not found then raise exception 'Worker lease lost'; end if;
select * into d from public.datasets where id=j.dataset_id for update;
if j.kind='map' then
if d.parent_dataset_id is not null then raise exception 'Contributions cannot refresh a guessing map'; end if;
if d.state not in ('draft','rejected','published') or d.update_state='review' or d.contribution_status in ('submitted','accepted','rejected') then raise exception 'Map cannot change during review'; end if;
update public.dataset_inputs set manifest=manifest||jsonb_build_object('mapPath',d.id::text||'/maps/'||j.id::text||'.webp') where dataset_id=d.id and validated;
if not found then raise exception 'Dataset not validated'; end if;
update public.datasets set update_state=case when state='published' then 'draft' else null end,state=case when state='published' then state else 'draft' end,rejection_reason=null,updated_at=now() where id=d.id;
update public.upload_jobs set state='complete',progress=100,lease_until=null,error=null,updated_at=now() where id=j.id;
return;
end if;
if p_count<(case when d.parent_dataset_id is null then 5 else 1 end) or p_count>24 then raise exception 'Invalid panorama count'; end if;
update public.datasets set state='draft',panorama_count=p_count,map_path=j.dataset_id::text||'/map.webp',cover_path=j.dataset_id::text||'/'||p_cover||'/preview.webp',updated_at=now() where id=j.dataset_id and state='processing';
if not found then raise exception 'Dataset processing cancelled'; end if;
update public.dataset_inputs set validated=true where dataset_id=j.dataset_id;
update public.upload_jobs set state='complete',progress=100,lease_until=null,error=null,updated_at=now() where id=p_job;
end; $$;
create or replace function public.submit_draft(p_dataset uuid,p_owner uuid) returns void
language plpgsql security invoker set search_path='' as $$
declare d public.datasets; count integer;
begin
perform public.check_contribution_server(p_dataset,p_owner);
select * into d from public.datasets where id=p_dataset and owner_id=p_owner for update;
if not found then raise exception 'Dataset not found'; end if;
if d.state not in ('draft','rejected','published') or (d.state='published' and coalesce(d.update_state,'') not in ('draft','rejected')) or d.contribution_status in ('submitted','accepted','rejected') then raise exception 'Draft is not ready for submission'; end if;
if exists(select 1 from public.upload_jobs where dataset_id=d.id and state in ('queued','running')) then raise exception 'Wait for map rendering to finish before submitting'; end if;
select jsonb_array_length(manifest->'viewpoints') into count from public.dataset_inputs where dataset_id=p_dataset and validated;
if count is null or count<(case when d.parent_dataset_id is null then 5 else 1 end) or count>24 then raise exception 'Invalid panorama count'; end if;
update public.datasets set state=case when state='published' then state else 'review' end,
update_state=case when state='published' then 'review' end,
contribution_status=case when parent_dataset_id is not null then 'submitted' end,updated_at=now() where id=p_dataset;
end; $$;

View File

@@ -1,7 +1,7 @@
-- Run against the configured project; all fixture changes roll back. -- Run against the configured project; all fixture changes roll back.
begin; begin;
do $$ do $$
declare original public.datasets; fixture uuid; job uuid; owner uuid; moderator uuid; before_path text; before_revision uuid; declare original public.datasets; fixture uuid; fixture_child uuid; job uuid; child_job uuid; owner uuid; moderator uuid; before_path text; before_revision uuid;
begin begin
select * into original from public.datasets where state='published' and parent_dataset_id is null limit 1; select * into original from public.datasets where state='published' and parent_dataset_id is null limit 1;
if not found then raise exception 'A published fixture source is required'; end if; if not found then raise exception 'A published fixture source is required'; end if;
@@ -53,5 +53,21 @@ begin
exception when others then exception when others then
if sqlerrm<>'Capture boundaries must match the map exactly' then raise; end if; if sqlerrm<>'Capture boundaries must match the map exactly' then raise; end if;
end; end;
insert into public.datasets(owner_id,server_id,name,source,bounds,state,parent_dataset_id,contribution_status,contribution_count)
values(owner,original.server_id,'Refresh guard contribution',original.source,original.bounds,'draft',fixture,'pending',1) returning id into fixture_child;
begin
perform public.refresh_map(fixture_child,owner,original.id);
raise exception 'Contribution refresh succeeded';
exception when others then
if sqlerrm<>'Only a map can refresh its guessing map' then raise; end if;
end;
insert into public.upload_jobs(dataset_id,kind,source_dataset_id,state,lease_owner,lease_until)
values(fixture_child,'map',original.id,'running','regression',now()+interval '2 minutes') returning id into child_job;
begin
perform public.complete_job(child_job,'regression',0,'');
raise exception 'Contribution map render completed';
exception when others then
if sqlerrm<>'Contributions cannot refresh a guessing map' then raise; end if;
end;
end; $$; end; $$;
rollback; rollback;

View File

@@ -105,14 +105,15 @@ async function processJob(job: {
.single(), .single(),
); );
const manifest = datasetSchema.parse(input.manifest); const manifest = datasetSchema.parse(input.manifest);
await progress(0, `Downloading ${manifest.assets.length} export files`); const assets = manifest.assets.filter(
let downloaded = 0;
for (const asset of manifest.assets.filter(
(a) => (a) =>
!mapOnly || !mapOnly ||
a.path === a.path ===
(manifest.source === "plugin" ? "terrain.json.gz" : "map.png"), (manifest.source === "plugin" ? "terrain.json.gz" : "map.png"),
)) { );
await progress(0, `Downloading ${assets.length} export files`);
let downloaded = 0;
for (const asset of assets) {
if (abort.signal.aborted) throw new Error("Lease lost or cancelled"); if (abort.signal.aborted) throw new Error("Lease lost or cancelled");
const blob = check( const blob = check(
await client.storage await client.storage
@@ -129,8 +130,8 @@ async function processJob(job: {
await mkdir(path.slice(0, path.lastIndexOf("/")), { recursive: true }); await mkdir(path.slice(0, path.lastIndexOf("/")), { recursive: true });
await writeFile(path, bytes); await writeFile(path, bytes);
await progress( await progress(
(20 * ++downloaded) / manifest.assets.length, (20 * ++downloaded) / assets.length,
`Downloaded ${downloaded}/${manifest.assets.length} export files`, `Downloaded ${downloaded}/${assets.length} export files`,
); );
} }
const output = `${directory}/media`; const output = `${directory}/media`;