Fill Recipes grids without teaching the recipe

This commit is contained in:
GabrielNichols committed 2026-11-06 19:10:19 -03:00
1 parent 9d11d0c710
commit c7d27a84a1
12 files changed
+337 -80

No files matched your search

@@ -2662,6 +2662,9 @@ impl Game<'_> {
S::CustomPayload(p) if p.channel == horizon_client::lod_net::CHANNEL => {
self.guest_far_terrain(host, conn, &p.data)
}
S::CustomPayload(p) if p.channel == horizon_client::lan::recipes::TRANSFER_CHANNEL => {
self.guest_recipe_transfer(host, conn, &p.data);
}
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) {
+66 -15
View File
@@ -10,7 +10,10 @@ use horizon_client::{
furnace::FurnaceCooking,
items::ItemStack,
lan::{guest::Guest, menu::Slots},
menu::{Env, Input, Kind, Menu},
menu::{
Env, Input, Kind, Menu,
place::{PostPlace, TransferError},
},
};
use horizon_protocol::packets::play::{self, Clientbound as C};
@@ -441,7 +444,6 @@ impl Game<'_> {
conn: u32,
p: &horizon_protocol::packets::recipes::PlaceRecipe,
) {
use horizon_client::menu::place::PostPlace;
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
@@ -470,7 +472,63 @@ impl Game<'_> {
let (player, name) = (g.uuid, g.name.clone());
self.native_player_recipe_book_click(player, &name, &id, p.use_max);
let creative = g.creative();
let placed = if p.container == 0 {
let placed = self.lan_apply_recipe(host, gi, p.container, |menu, env| {
menu.place_recipe(env, &id, p.use_max, creative, None)
});
if placed == Some(PostPlace::Ghost) {
host.hub.send_ghost_recipe(conn, p.container, &id);
}
}
pub(super) fn guest_recipe_transfer(&mut self, host: &mut LanHost, conn: u32, data: &[u8]) {
use horizon_protocol::buf::{Reader, Writer};
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let g = &host.hub.guests[gi];
if g.brand != "horizon" || g.spectator() {
return;
}
let mut reader = Reader::new(data);
let (Ok(container), Ok(id)) = (reader.varint(), reader.string(512)) else {
return;
};
if reader.remaining() != 0 {
return;
}
let result = if container == 0 && (g.menu.is_some() || g.creative()) {
Err(TransferError::Unavailable)
} else {
self.lan_apply_recipe(host, gi, container, |menu, env| {
menu.transfer_recipe(env, &id)
})
.unwrap_or(Err(TransferError::Unavailable))
};
let mut reply = Writer::new();
reply.varint(container);
reply.u8(match result {
Ok(()) => 0,
Err(TransferError::Missing) => 1,
Err(TransferError::NoRoom) => 2,
Err(TransferError::Unavailable) => 3,
});
host.hub.guests[gi].send(&C::CustomPayload(
horizon_protocol::packets::configuration::CustomPayload {
channel: horizon_client::lan::recipes::TRANSFER_CHANNEL.into(),
data: reply.data,
},
));
}
fn lan_apply_recipe<R>(
&mut self,
host: &mut LanHost,
gi: usize,
container: i32,
apply: impl FnOnce(&mut Menu, &Env) -> R,
) -> Option<R> {
let conn = host.hub.guests[gi].conn;
let placed = if container == 0 {
let crafting = host.play.entry(conn).or_default();
let mut menu = crafting
.crafting
@@ -482,7 +540,7 @@ impl Game<'_> {
for (k, s) in grid.into_iter().enumerate() {
menu.set_slot(env, 1 + k, s);
}
menu.place_recipe(env, &id, p.use_max, creative, None)
apply(menu, env)
});
let g = &mut host.hub.guests[gi];
for k in 0..4 {
@@ -493,23 +551,16 @@ impl Game<'_> {
host.hub.send_inventory(gi);
placed
} else {
let Some((window, state, mut menu, pos)) = host
let (window, state, mut menu, pos) = host
.play
.get_mut(&conn)
.and_then(|play| play.crafting.table.take_if(|(w, ..)| *w == p.container))
else {
return;
};
let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
menu.place_recipe(env, &id, p.use_max, creative, None)
});
.and_then(|play| play.crafting.table.take_if(|(w, ..)| *w == container))?;
let placed = self.guest_menu_op(host, gi, &mut menu, apply);
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos));
self.lan_send_table_content(host, conn);
placed
};
if placed == PostPlace::Ghost {
host.hub.send_ghost_recipe(conn, p.container, &id);
}
Some(placed)
}
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
@@ -9980,6 +9980,11 @@ impl Game<'_> {
pub(super) fn native_place_recipe(&mut self, book_id: i32, all: bool) -> Result<(), String> {
self.recipe_place(book_id, all)
}
/// Move ingredients for a viewer recipe into the open crafting grid.
/// Unlike the recipe book click, this doesn't require learning the recipe.
pub(super) fn native_transfer_recipe(&mut self, book_id: i32) {
self.transfer_viewer_recipe(book_id);
}
/// Creative mode only: gives the player `count` of `item`
/// (`minecraft:diamond`) as taking it from the creative inventory does
/// (`ServerboundSetCreativeModeSlot`): the selected slot first, then the
@@ -69,6 +69,7 @@ pub(super) struct Panel {
searching: bool,
/// The recipes shown instead of the items.
pub(super) recipes: Option<super::native_panel_recipes::Pages>,
pub(super) transfer_warning: Option<(&'static str, &'static str)>,
}
impl Panel {
@@ -116,6 +117,7 @@ impl Panel {
/// nothing, except while filtering the item list with a non-empty query.
fn native_changed(&mut self, keep_search: bool) {
self.versions += 1;
self.transfer_warning = None;
if let Some(shown) = self.native.as_mut() {
shown.version = self.versions;
if shown.items.is_empty() && shown.recipes.is_none() && !keep_search {
@@ -499,10 +501,20 @@ impl Game<'_> {
}
/// The container screen closed: the search box lets go of the keys.
pub(super) fn mod_browser_closed(&mut self) {
self.panel.transfer_warning = None;
if let Some(search) = self.panel.search.as_mut() {
search.focused = false;
}
}
pub(super) fn close_transferred_recipe(&mut self) {
if let Some(owner) = self.browser_sync()
&& owner.native_id() == "recipes"
&& self.panel.recipes.is_some()
{
self.mod_browser_closed();
self.panel_event(&owner, BrowserEvent::Back);
}
}
/// The item under the pointer on a container screen, for the mods' keys
/// (the `Key` event): the panel's, else a slot's.
pub(super) fn mod_browser_pointed(&mut self) -> Option<BrowserStack> {
@@ -453,6 +453,17 @@ impl Game<'_> {
);
}
}
if let Some((key, fallback)) = self.panel.transfer_warning {
let warning = self.translate(key, fallback);
let width = self.gui.atlas.font.width(&warning);
horizon_client::widgets::record(&warning, [cursor.0, cursor.1, width, 9.0], false);
self.draw_tooltip_lines(
batch,
&[(warning, rgba(255, 100, 100, 255))],
cursor.0,
cursor.1,
);
}
}
/// The tabs, on slots; the selected one framed.
@@ -1019,6 +1019,100 @@ impl Game<'_> {
self.recipe_ui.ghost.clear();
self.place_recipe(BookType::Crafting, index, all);
}
pub(super) fn transfer_viewer_recipe(&mut self, display_id: i32) {
self.panel.transfer_warning = None;
if !self.mod_recipe_screen() || (self.creative && self.craft_size != 3) {
self.show_transfer_result(3);
return;
}
if self.remote.as_ref().is_some_and(|r| !r.recipe_transfer) {
// Older Horizon and vanilla servers still accept book clicks.
if self.recipe_place(display_id, false).is_err() {
self.show_transfer_result(4);
}
return;
}
self.ensure_book();
let Some(id) = self
.recipe_ui
.book
.as_ref()
.and_then(|book| {
book.entries
.iter()
.find(|entry| entry.display_id == display_id)
})
.map(|entry| entry.id.clone())
else {
self.show_transfer_result(3);
return;
};
if self.remote.is_some() {
let mut request = horizon_protocol::buf::Writer::new();
request.varint(self.remote_menu_id());
request.string(&id);
self.send(horizon_protocol::packets::play::Serverbound::CustomPayload(
horizon_protocol::packets::configuration::CustomPayload {
channel: horizon_client::lan::recipes::TRANSFER_CHANNEL.into(),
data: request.data,
},
));
return;
}
use horizon_client::menu::place::TransferError;
let mut result = Err(TransferError::Unavailable);
self.menu_session(|menu, env, _| {
result = menu.transfer_recipe(env, &id);
Some(result.is_ok())
});
self.show_transfer_result(match result {
Ok(()) => 0,
Err(TransferError::Missing) => 1,
Err(TransferError::NoRoom) => 2,
Err(TransferError::Unavailable) => 3,
});
if result.is_ok() {
self.recipe_book_changed();
}
}
pub(super) fn remote_recipe_transfer(&mut self, data: &[u8]) {
if data.is_empty() {
if let Some(remote) = &mut self.remote {
remote.recipe_transfer = true;
}
return;
}
let mut reader = horizon_protocol::buf::Reader::new(data);
if let (Ok(container), Ok(result)) = (reader.varint(), reader.u8())
&& reader.remaining() == 0
&& container == self.remote_menu_id()
&& self.mod_recipe_screen()
&& self.panel.recipes.is_some()
{
self.show_transfer_result(result);
}
}
fn show_transfer_result(&mut self, result: u8) {
if result == 0 {
self.close_transferred_recipe();
}
self.panel.transfer_warning = match result {
1 => Some(("horizon.recipe_transfer.missing", "Missing Items")),
2 => Some(("horizon.recipe_transfer.no_room", "Inventory full")),
3 => Some((
"horizon.recipe_transfer.unavailable",
"Recipe does not fit this menu",
)),
4 => Some((
"horizon.recipe_transfer.unsupported",
"Server requires a known recipe",
)),
_ => None,
};
}
/// `ServerboundPlaceRecipePacket`: the server places the recipe
/// (`ServerPlaceRecipe` on the menu engine) and answers with a ghost
/// recipe when the items are missing.
+5
View File
@@ -498,6 +498,7 @@ enum OwnProfile {
pub(super) struct Remote {
pub session: Session,
pub brand: String,
pub(super) recipe_transfer: bool,
/// This connection's configured feature flags, carried through reconfiguration.
pub(super) features: Vec<String>,
/// The server announced it runs mods' quiet commands.
@@ -1088,6 +1089,7 @@ impl Game<'_> {
self.remote = Some(Remote {
session,
brand: "unknown".into(),
recipe_transfer: false,
features: vec!["minecraft:vanilla".into()],
quiet_commands: false,
mod_net: false,
@@ -3337,6 +3339,9 @@ impl Game<'_> {
Err(e) => log::debug!("Horizon: a bad menu extra payload: {e}"),
}
}
C::CustomPayload(p) if p.channel == horizon_client::lan::recipes::TRANSFER_CHANNEL => {
self.remote_recipe_transfer(&p.data);
}
// The server's recipes its native mods asked to send.
C::CustomPayload(p) if p.channel == horizon_client::lan::recipes::CONTENT_CHANNEL => {
self.remote_recipe_content(&p.data);
+6
View File
@@ -1329,6 +1329,12 @@ impl Hub {
// for, to a Horizon client only.
if g.brand == "horizon" {
send_recipe_content(g);
g.send(&C::CustomPayload(
horizon_protocol::packets::configuration::CustomPayload {
channel: super::recipes::TRANSFER_CHANNEL.into(),
data: Vec::new(),
},
));
}
// PlayerList.sendPlayerPermissionLevel, then the recipe book.
self.send_permission_level(i);
+4
View File
@@ -66,6 +66,10 @@ fn recipes(dir: &Path, namespace: &str, out: &mut BTreeMap<String, Value>) {
}
}
/// Horizon's optional recipe viewer transfer. An empty clientbound payload
/// advertises support. Requests carry a menu id and resource location.
pub const TRANSFER_CHANNEL: &str = "horizon:recipe_transfer";
/// The recipes by id in vanilla's registration order (path, then namespace),
/// the order `RecipeManager` numbers groups and lists the stonecutter's
/// recipes in, and so the order the host's own tables keep.
+56 -3
View File
@@ -298,6 +298,14 @@ pub enum PostPlace {
Ghost,
}
/// Why a viewer transfer could not move a complete set of ingredients.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TransferError {
Missing,
NoRoom,
Unavailable,
}
/// The menu slots a placement works on.
struct Grid {
width: usize,
@@ -384,7 +392,8 @@ impl Menu {
}
let ingredients: Vec<&Ingredient> = placement.ingredients.iter().collect();
let post = if stacked.try_pick(&ingredients, 1, None) {
self.place(env, id, &grid, &placement, &mut stacked, use_max);
let matches = self.recipe_in_grid(env, id);
self.place(env, &grid, &placement, &mut stacked, use_max, matches);
PostPlace::Nothing
} else {
self.clear_grid(env, &grid);
@@ -395,6 +404,51 @@ impl Menu {
post
}
/// Move one set of ingredients from the player's real inventory and grid.
/// The viewer leaves both alone on failure, including in creative mode.
pub fn transfer_recipe(&mut self, env: &Env, id: &str) -> Result<(), TransferError> {
if !matches!(self.kind, Kind::Inventory | Kind::Crafting) || self.carried.is_some() {
return Err(TransferError::Unavailable);
}
let grid = self.place_grid().ok_or(TransferError::Unavailable)?;
let placement = env
.recipes
.placement(id)
.ok_or(TransferError::Unavailable)?;
if placement.impossible()
|| placement.ingredients.len() > grid.inputs.len()
|| placement
.shaped
.is_some_and(|(w, h)| w > grid.width || h > grid.height)
{
return Err(TransferError::Unavailable);
}
let mut stacked = self.stacked_contents(env);
let ingredients: Vec<_> = placement.ingredients.iter().collect();
if !stacked.try_pick(&ingredients, 1, None) {
return Err(TransferError::Missing);
}
// The vanilla placement can return leftover grid items as drops.
// A viewer transfer must keep the original slots if it cannot move
// every input without dropping anything.
let previous = self.containers.clone();
let (drops, dropped) = (self.drops.len(), self.dropped.len());
self.place(env, &grid, &placement, &mut stacked, false, false);
let no_room = self.dropped.len() != dropped;
if no_room || !self.recipe_in_grid(env, id) {
self.containers = previous;
self.drops.truncate(drops);
self.dropped.truncate(dropped);
return Err(if no_room {
TransferError::NoRoom
} else {
TransferError::Unavailable
});
}
self.recompute(env);
Ok(())
}
/// `recipeMatches`: the recipe already fills the grid.
fn recipe_in_grid(&self, env: &Env, id: &str) -> bool {
match self.kind {
@@ -417,14 +471,13 @@ impl Menu {
fn place(
&mut self,
env: &Env,
id: &str,
grid: &Grid,
placement: &Placement,
stacked: &mut Stacked,
use_max: bool,
matches: bool,
) {
let ingredients: Vec<&Ingredient> = placement.ingredients.iter().collect();
let matches = self.recipe_in_grid(env, id);
let biggest = stacked.try_pick_all(&ingredients, i32::MAX, None);
if matches {
for &s in &grid.inputs {
+4 -4
View File
@@ -71,10 +71,10 @@ Recipes mod and a matching `horizon-ctl`. This opt-in check fails if Recipes is
LAN runs do not require it.
The runner installs the same original `tools/fixtures/recipes_hmod` package in both scratch game
folders. It finds the output of an original recipe (flint to two echo shards), checks unknown recipes
and missing ingredients, fills the
crafting grid through Recipes' `+` button, takes the result, and checks authoritative inventory counts
before and after Save and Quit/rejoin. Logs, screenshots and `recipes-hmod.json` go under `--out`.
folders. It finds the output of an original recipe (flint to two echo shards) in Survival and Creative.
With the recipe book empty, it verifies that missing ingredients show a warning and preserve the
occupied grid. It then fills the grid through Recipes' `+`, takes the result, and checks authoritative
inventory counts before and after Save and Quit/rejoin. Logs, screenshots and `recipes-hmod.json` go under `--out`.
As an assertion canary, changing only the fixture's result count from two to one must fail the custom
result check; restore it before running the passing case. This is a content scenario, not general
Java-mod compatibility or custom-item registration coverage.
+71 -58
View File
@@ -13215,15 +13215,9 @@ def prepare_recipes_hmod(*games):
(settings / 'give').write_text('false')
def check_recipes_hmod(host, guest, out):
"""Recipes' real panel -> normal server menu -> custom output -> save/rejoin (#9647).
Opt in with --check recipes-hmod and --bin-dir pointing at a game compiled with the official
native Recipes mod. The fixture is ours; no Java mod, copied assets or synthetic game replies.
"""
def exercise_recipes_hmod(game, out, mode, supplied):
"""Use the real Recipes panel and the authoritative crafting table."""
item = 'minecraft:echo_shard'
recipe = 'recipe_pilot:flint_to_echo_shard'
seen = {}
def widget(game, predicate):
matches = [w for w in game.raw('ui')['widgets'] if predicate(w)]
@@ -13256,70 +13250,89 @@ def check_recipes_hmod(host, guest, out):
def contents(game):
return {'flint': server_count(game, 'flint'), 'echo_shard': server_count(game, item)}
for game, supplied in ((host, 4), (guest, 5)):
game('command', command='clear @s')
server_reply(game, 'recipe take @s *', 'recipe')
game('command', command=f'gamemode {mode}')
game.wait(10)
game.until(lambda: game('state')['gamemode'], lambda current: current == mode)
game('command', command='clear @s')
game('command', command='give @s minecraft:dirt 1')
table = None
if mode == 'creative':
x, y, z = (math.floor(v) for v in pos_of(game))
table = (x, y, z - 2)
game('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:crafting_table')
game.wait(10)
game.raw('interact', *table)
game.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
assert game('screen').get('craft_size') == 3
else:
game.raw('key', 'e', 'tap')
game.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
assert game('screen').get('craft_size') == (3 if table else 2)
# The output exists in vanilla; its flint recipe is original .hmod content.
search(game, 'recipe_pilot:no_such_item')
assert not any(w.get('label') == 'Echo Shard' for w in game.raw('ui')['widgets'])
search(game, item)
game.shot(out, 'recipes-hmod-search')
unknown = page(game)
assert unknown['enabled'] is False, f'{game.name}: unknown recipe can be placed: {unknown}'
game.shot(out, 'recipes-hmod-unknown')
assert not any(i for i, _ in grid_of(game, 100, 109).values())
assert not result_of(game)[0]
# Put an unrelated item in the grid so a refused transfer cannot pass by
# merely clearing an already empty grid.
game.raw('click-slot', hotbar_slot(game, 'minecraft:dirt'))
game.raw('click-slot', 100)
game.wait(10)
before = grid_of(game, 100, 109)
assert before[100] == ('minecraft:dirt', 1), before
assert not result_of(game)[0], f'{mode}: control ingredient must have no output'
server_reply(game, 'recipe take @s *', 'recipe')
game.until(lambda: game('screen')['recipe_book']['known'], lambda count: count == 0)
server_reply(game, f'recipe give @s {recipe}', 'recipe')
game.wait(10)
back = widget(game, lambda w: w.get('label') == 'Back')
game.raw('click', back['id'])
game.wait(5)
known = page(game)
assert known['enabled'] is True, f'{game.name}: learned custom recipe unavailable: {known}'
game.raw('click', known['id'])
game.wait(10)
missing = contents(game)
assert missing == {'flint': 0, 'echo_shard': 0}, missing
assert not any(i for i, _ in grid_of(game, 100, 109).values())
assert not result_of(game)[0], f'{game.name}: missing ingredients fabricated a result'
game.shot(out, 'recipes-hmod-missing')
search(game, 'recipe_pilot:no_such_item')
assert not any(w.get('label') == 'Echo Shard' for w in game.raw('ui')['widgets'])
search(game, item)
transfer = page(game)
assert transfer['enabled'], f'{mode}: + must be available before unlock'
game.raw('click', transfer['id'])
game.until(lambda: game.raw('ui')['widgets'],
lambda widgets: any(w.get('label') == 'Missing Items' for w in widgets))
assert grid_of(game, 100, 109) == before, f'{mode}: missing ingredients changed the grid'
assert not result_of(game)[0], f'{mode}: missing ingredients fabricated a result'
missing = contents(game)
assert missing == {'flint': 0, 'echo_shard': 0}, missing
game.shot(out, f'{mode}-missing')
# Supply the input through the server, then use only the panel's normal + action.
game('command', command=f'give @s minecraft:flint {supplied}')
game.wait(10)
known = widget(game, lambda w: (w.get('label') or '').startswith('+ '))
game.raw('click', known['id'])
game.until(lambda: result_of(game), lambda r: bool(r[0]))
grid = [s for s in grid_of(game, 100, 109).values() if s[0]]
result = result_of(game)
assert grid == [('minecraft:flint', 1)], f'{game.name}: filled grid {grid}'
# Independent expected yield: changing the fixture to yield one must fail here.
assert result == (item, 2), f'{game.name}: custom result {result}'
game.shot(out, 'recipes-hmod-filled')
game.raw('click-slot', 200, 'shift')
game.wait(10)
assert not result_of(game)[0], f'{game.name}: consumed input left another result'
back_to_game(game)
crafted = contents(game)
assert crafted == {'flint': supplied - 1, 'echo_shard': 2}, crafted
seen[game.name] = {'missing': missing, 'grid': grid, 'result': result, 'crafted': crafted}
game('command', command=f'give @s minecraft:flint {supplied}')
server_reply(game, 'recipe take @s *', 'recipe')
game.until(lambda: game('screen')['recipe_book']['known'], lambda count: count == 0)
transfer = widget(game, lambda w: (w.get('label') or '').startswith('+ '))
game.raw('click', transfer['id'])
game.until(lambda: result_of(game), lambda r: bool(r[0]))
grid = [s for s in grid_of(game, 100, 109).values() if s[0]]
result = result_of(game)
assert grid == [('minecraft:flint', 1)], f'{mode}: filled grid {grid}'
assert result == (item, 2), f'{mode}: custom result {result}'
assert game('screen')['recipe_book']['known'] == 0, f'{mode}: transfer learned a recipe'
assert server_count(game, 'dirt') == 1, f'{mode}: displaced ingredient was lost'
assert server_count(game, 'flint') == supplied - 1, f'{mode}: transfer fabricated an input'
assert not any(w.get('label') == 'Missing Items' for w in game.raw('ui')['widgets'])
game.wait(140)
game.shot(out, f'{mode}-filled')
game.raw('click-slot', 200, 'shift')
game.wait(10)
assert not result_of(game)[0], f'{mode}: consumed input left another result'
back_to_game(game)
crafted = contents(game)
assert crafted == {'flint': supplied - 1, 'echo_shard': 2}, crafted
if table:
game('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:air')
return {'mode': mode, 'missing': missing, 'warning': 'Missing Items',
'grid_unchanged_on_failure': True, 'known_before_and_after_fill': 0,
'grid': grid, 'result': result, 'crafted': crafted}
def check_recipes_hmod(host, guest, out):
"""Exercise Survival and Creative through the real host/guest server."""
seen = {
host.name: exercise_recipes_hmod(host, out, 'survival', 4),
guest.name: exercise_recipes_hmod(guest, out, 'creative', 5),
}
save_and_reopen(host, guest)
for game in (host, guest):
persisted = contents(game)
persisted = {'flint': server_count(game, 'flint'), 'echo_shard': server_count(game, 'echo_shard')}
assert persisted == seen[game.name]['crafted'], f'{game.name}: after rejoin {persisted}'
seen[game.name]['rejoined'] = persisted
game.shot(out, 'recipes-hmod-rejoined')
game.shot(out, f"{seen[game.name]['mode']}-rejoined")
(out / 'recipes-hmod.json').write_text(json.dumps(seen, indent=2) + '\n')
return seen