sorcery  Artifact [62888214aa]

Artifact 62888214aa7bd7a5a7f7c51fb8dc62f9f35a754da9109e938e7e5db02e6ec1ad:

  • File cookbook.lua — part of check-in [3f6a913e4e] at 2020-09-29 12:40:28 on branch trunk — * remove former hacky registration system, replace with consistent and flexible API; rewrite metal/gem generation to take advantage of this new API; tweaks to init system to enable world-local tweaks to lore and sorcery behavior * initial documentation commit * initial steps towards calendar - add default date format, astrolabe; prepare infra for division/melding/transmutation spells, various tweaks and fixes (user: lexi, size: 19568) [annotate] [blame] [check-ins using]

-- by use of the enchanter, it is possible to reveal a random
-- recipe and enscribe it on a sheet of paper. these sheets of
-- paper can then bound together into books, combining like
-- recipes

sorcery.cookbook = {}
local constants = {
	-- do not show recipes for items in these groups
	exclude_groups = {
	};
	exclude_names = {
		'stairs';
		'slab';
		'slope';
	};
	-- do not show recipes from this namespace
	blacklist_mods = {
		'group'; -- WHY IS THIS NECESSARY
		'moreblocks'; -- too much noise
	};

	recipes_per_cookbook_page = 3;

	group_ids = {
		wood   = { caption = 'Any Wood',   cnitem = 'default:wood'   };
		tree   = { caption = 'Any Tree',   cnitem = 'default:tree'   };
		leaves = { caption = 'Any Leaves', cnitem = 'default:leaves' };
		stone  = { caption = 'Any Stone',  cnitem = 'default:stone'  };
		dye    = { caption = 'Any Dye',    cnitem = 'dye:black'      };
		bone   = { caption = 'Any Bone',   cnitem = 'bonemeal:bone'  };
		vessel = { caption = 'Any Bottle', cnitem = 'vessels:glass_bottle' };
		flower = { caption = 'Any Flower', cnitem = 'flowers:rose' };
		mushroom = { caption = 'Any Mushroom', cnitem = 'flowers:mushroom_brown' };
		water_bucket = { caption = 'Water Bucket', cnitem = 'bucket:bucket_water' };
		sorcery_ley_cable = { caption = 'Cable', cnitem = 'sorcery:cable_vidrium' };
	};
}
sorcery.cookbook.constants = constants

local slot3x3 = {
	{0,0}, {1,0}, {2,0};
	{0,1}, {1,1}, {2,1};
	{0,2}, {1,2}, {2,2};
}
local props_builtin = function(item)
	local props = minetest.registered_items[item]._sorcery
	if props and props.recipe then
		return props.recipe
	end
	return {}
end
local modofname = function(id)
	local sep = string.find(id,':')
	if sep == nil then return nil end -- uh oh
	return string.sub(id, 1, sep - 1)
end
local pick_builtin = function(kind) return function(restrict)
	-- ow ow ow ow ow ow ow
	local names = {}
	for k in pairs(minetest.registered_items) do
		local rec = minetest.get_craft_recipe(k)
		if rec.items ~= nil and (rec.method == kind or (rec.method == 'shapeless' and kind == 'normal')) then -- is this last bit necessary?
			local excluded = false
			for _,n in pairs(constants.exclude_names) do
				if string.find(k,n) ~= nil then
					excluded = true break end
			end
			if not excluded then for _,g in pairs(constants.exclude_groups) do
				if minetest.get_item_group(k, g) > 0 then
					excluded = true break end
			end end
			local props = minetest.registered_items[k]._sorcery
			local module = modofname(k)
			if not (excluded
				or sorcery.lib.tbl.has(constants.blacklist_mods,module)
				or (props and props.recipe and props.recipe.secret)
				or (restrict and (
				    (restrict.mod   and module ~= restrict.mod)
				 or (restrict.group and (minetest.get_item_group(k, restrict.group) == 0))
			))) then names[#names + 1] = k end
		end
	end
	return names[math.random(#names)]
end end
local find_builtin = function(method,kind)
	return function(out)
		local rec = {}
		local crec = sorcery.lib.tbl.walk(minetest.registered_items[out],{'_sorcery','recipe','canonical',kind})
		local w=0, lst
		if crec then
			lst = {}
			for i,v in pairs(crec) do
				if #v > w then w = #v end
				for j,n in pairs(v) do
					lst[#lst+1] = n
				end
			end
		else
			-- WHY IS THIS INTERFACE SO CLUMSY
			local all,i = minetest.get_all_craft_recipes(out), nil
			for _,r in pairs(all) do
				if r.method == method and r.items and #r.items>0 then
					i = r break
				end
			end
			if i == nil or i.items == nil or #i.items == 0 then return nil end
			w = (i.width == 0) and 3 or i.width
			lst = i.items
		end
		-- for j=1,#i.items do
		for j,item in pairs(lst) do
			local row = math.floor((j-1) / w)
			local col = (j-1) % w
			if item then
				rec[1 + (row * 3) + col] = item
			end
		end
		return rec
	end
end
local function group_eval(i)
	if string.sub(i,1,6) == 'group:' then
		local g = string.sub(i,7)
		if constants.group_ids[g] then
			return constants.group_ids[g].cnitem,
			       constants.group_ids[g].caption
		end
		for i,v in pairs(minetest.registered_items) do
			if minetest.get_item_group(i, g) > 0 then
				return i, v.description
			end
		end
		return i
	end
	return i
end
local function desc_builtin(i)
	local desc
	i, desc = group_eval(i)
	-- print('describing ',i,dump(minetest.registered_items[i]))
	local s = ItemStack(i)
	if not minetest.registered_items[s:get_name()] then
		minetest.log('WARNING: unknown item in recipe ' .. i)
		return 'Unknown Item'
	end
	if not desc then desc = minetest.registered_items[s:get_name()].description end
	if not desc then return 'Peculiar Item' end

	local eol = string.find(desc,'\n')
	if eol then desc = string.sub(desc,1,eol-1) end

	if s:get_count() > 1 then
		desc = string.format("%s (%u)",desc,s:get_count())
	end
	return desc
end;

local bookadjs = { -- sets are in reverse order!
	{'Celestial', 'Divine', 'Inspired', 'Heavenly';
	 'Mystic', 'Diabolic', 'Luminous', 'Forsaken'};

	{'Dark', 'Perfected', 'Flawless', 'Unthinkable';
	 'Impossible', 'Worrisome', 'Unimpeachable'};

	{'Splendid', 'Magnificent', 'Sublime', 'Grand';
	 'Beneficent', 'Mysterious', 'Peculiar', 'Eerie';
	 'Fulsome', 'Fearsome', 'Curious', 'Fascinating';
     'Notorious', 'Infamous'};
}

local cache = {
	populate_grindables = function(cache)
		if not cache.grindables then
			cache.grindables = {}
			for k,v in pairs(minetest.registered_items) do
				if sorcery.itemclass.get(k, 'grindable') then
					cache.grindables[#cache.grindables+1] = k
				end
			end
		end
	end;
}
sorcery.cookbook.classes = {
	craft = {
		name = 'Crafting Guide';
		node = 'xdecor:workbench';
		booksuf = 'Codex';
		w = 3, h = 3;
		chance = 2;
		slots = slot3x3;
		pick = pick_builtin('normal');
		find = find_builtin('normal','craft');
		props = props_builtin;
		apply_exclusions = true;
	};
	-- smelt = {
	-- 	w = 3, h = 3;
	-- 	slots = slot3x3;
	-- };
	cook = {
		name = 'Cooking Recipe';
		node = 'default:furnace';
		booksuf = 'Cookbook';
		w = 1, h = 1;
		chance = 3;
		slots = {{-0.2,0}};
		pick = pick_builtin('cooking');
		find = find_builtin('cooking','cook');
		props = props_builtin;
		apply_exclusions = true;
	};
	infuse = {
		name = 'Infusion Recipe';
		node = 'sorcery:infuser';
		booksuf = 'Pharmacopeia';
		w = 1, h = 2;
		chance = 4;
		slots = {
			{0,0};
			{0,1};
		};
		pick = function(restrict)
			-- TODO make sure affinity restrictions match
			return sorcery.data.infusions[math.random(#sorcery.data.infusions)].output
		end;
		title = function(output)
			for _,i in pairs(sorcery.data.infusions) do
				if i.output == output then
					if i._proto and i._proto.name
						then return i._proto.name
						else break end
				end
			end
			return 'Mysterious Potion'
		end;
		find = function(out)
			for _,i in pairs(sorcery.data.infusions) do
				if i.output == out then
					return { i.infuse, i.into }
				end
			end
		end;
		props = function(out)
			for _,i in pairs(sorcery.data.infusions) do
				if i.output == out then
					if i.recipe then return i.recipe else return {} end
				end
			end
		end;
	};
	grind = {
		name = 'Milling Guide';
		node = 'sorcery:mill';
		booksuf = 'Manual';
		chance = 1;
		w = 1, h = 2;
		pick = function(restrict)
			cache:populate_grindables()
			local i = cache.grindables[math.random(#cache.grindables)]
			local pd = sorcery.itemclass.get(i, 'grindable')
			return pd.powder
		end;
		props = props_builtin;
		slots = {
			{0,1},
			{0,0};
		};
		find = function(out)
			cache:populate_grindables()
			for _,v in pairs(cache.grindables) do
				local g = sorcery.itemclass.get(v,'grindable')
				if g.powder == out then
					if g.grindcost then
						v = v .. ' ' .. tostring(g.grindcost)
					end
					local mbh = sorcery.lib.tbl.keys(sorcery.data.metals)
					table.sort(mbh, function(a,b)
						return sorcery.data.metals[a].hardness < sorcery.data.metals[b].hardness
					end)
					for _,metal in pairs(mbh) do
						local md = sorcery.data.metals[metal]
						if ((not md.no_tools) or md.grindhead) and md.hardness >= g.hardness then
							return {v, 'sorcery:mill_grindhead_' .. metal}
						end
					end
					return {v,''} -- !!
				end
			end
		end;
	};
	-- wand = {
	--	booksuf = 'Grimoire';
	-- }
	enchant = {
		name = 'Enchantment Matrix';
		node = 'sorcery:enchanter';
		booksuf = 'Grimoire';
		drawslots = false;
		chance = 6;
		w = 2, h = 2;
		pick = function(restrict)
			-- TODO make sure affinity restrictions match
			local names = {}
			for k,v in pairs(sorcery.data.enchants) do
				if v.recipe then names[#names+1] = k end
			end
			return names[math.random(#names)]
		end;
		icon = function(name)
			return 'sorcery_enchant_' .. name .. '.png'
		end;
		find = function(name)
			local rec = {}
			local en = sorcery.data.enchants[name]
			if not en then return nil end
			en = en.recipe
			for i,e in pairs(en) do
				if e.lens then
					rec[i] = 'sorcery:lens_' .. e.lens .. '_' .. e.gem
				end
			end
			return rec
		end;
		props = function(name)
			return sorcery.data.enchants[name].info or {}
		end;
		slots = {
				{0.5,0};
			{0,1},   {1,1}
		};
		title = function(name) return sorcery.data.enchants[name].name end;
		outdesc = function(name,suffix)
			local e = sorcery.data.enchants[name]
			return sorcery.lib.ui.tooltip {
				title = e.name;
				desc = sorcery.lib.str.capitalize(e.desc);
				color = sorcery.lib.color(e.tone):readable();
			}
		end;
	};
	-- spells = {
	--  booksuf = 'Spellbook';
	--	slots = {
	--		{0,0}, {1,0};
	--		{0,1}, {1,1};
	--	};
	-- };
}

local recipe_kinds = sorcery.cookbook.classes

local namebook = function(kind,author)
	local name
	if kind then name = recipe_kinds[kind].booksuf
	        else name = 'Cyclopedia' end
	for _,set in pairs(bookadjs) do
		if math.random(3) == 1 then
			name = set[math.random(#set)] .. ' ' .. name
		end
	end

	return sorcery.lib.str.capitalize(author) .. "'s " .. name
end

sorcery.cookbook.pickrecipe = function(kind,restrict)
	if kind == nil then
		for k,v in pairs(recipe_kinds) do
			if math.random(v.chance) == 1 then
				kind = k break
			end
		end
		if kind == nil then -- oh well, we tried
			local rks = sorcery.lib.tbl.keys(recipe_kinds)
			kind = rks[math.random(#rks)]
		end
	end

	return recipe_kinds[kind].pick(restrict), kind
end

local render_recipe = function(kind,ingredients,result,notes_right)
	local k = recipe_kinds[kind]
	local t = ''
	local props = k.props(result)
	for i=1,#k.slots do
		local ing = ingredients[i]
		local x, y = k.slots[i][1], k.slots[i][2]
		if ing and ing ~= '' then
			local tt
			if k.indesc then tt = k.indesc(ing) else tt = desc_builtin(ing) end
			t = t .. string.format([[
				item_image[%f,%f;1,1;%s]
				tooltip[%f,%f;1,1;%s]
			]], x,y, minetest.formspec_escape(group_eval(ing)),
			    x,y, minetest.formspec_escape(tt))
		else
			if k.drawslots == nil or k.drawslots then
				t = string.format('box[%f,%f;0.1,0.1;#00000060]',x+0.45,y+0.45) .. t
			end
		end
	end
	local img, ot
	if props.note then
		local nx, ny, nw, nh
		if notes_right then
			nx = 5.25 ny = 0
			nw = 4 nh = 3
		else
			nx = 0 ny = 3
			nw = 4 nh = 1
		end
		t = t .. string.format([[
			hypertext[%f,%f;%f,%f;note;<global valign=middle halign=justify size=20>%s]
		]], nx,ny,nw,nh, minetest.formspec_escape(props.note))
	end
	if k.icon then img = k.icon(result) end
	if k.outdesc then ot = k.outdesc(result) else ot = desc_builtin(result) end
		-- image[%f,%f;1,1;gui_furnace_arrow_bg.png^[transformR270]
	return t .. string.format([[
		item_image[%f,%f;1,1;%s]tooltip[%f,%f;1,1;%s]
		]] --[[box[%f,%f;1,1;#850083A0]] .. [[
		%s[%f,%f;1,1;%s]
		tooltip[%f,%f;1,1;%s]
	]], k.w, k.h/2 - 0.5, k.node,
		k.w, k.h/2 - 0.5, minetest.formspec_escape(minetest.registered_nodes[k.node].description),
			-- k.w+1, k.h/2 - 0.5,
		img and 'image' or 'item_image',
			k.w+1.1, k.h/2 - 0.5, minetest.formspec_escape(img or result),
			k.w+1.1, k.h/2 - 0.5, minetest.formspec_escape(ot))
end;

local retrieve_recipe = function(kind,out,notes_right)
	local rec = recipe_kinds[kind]
	local ing = rec.find(out)
	return render_recipe(kind,ing,out,notes_right), rec.w, rec.h
end

sorcery.cookbook.setrecipe = function(stack,k,r,restrict)
	local meta = stack:get_meta()
	if not r then r,k = sorcery.cookbook.pickrecipe(k,restrict) end
	local t = recipe_kinds[k]
	meta:set_string('recipe_kind', k)
	meta:set_string('recipe_name', r)
	meta:set_string('description',
		(t.title and t.title(r) or desc_builtin(r)) .. ' ' .. t.name)
end

minetest.register_craftitem('sorcery:recipe', {
	description = 'Recipe';
	inventory_image = 'default_paper.png'; -- fixme
	groups = { flammable = 1; book = 1; paper = 1; };
	stack_max = 1;
	on_use = function(itemstack, user, target)
		local meta = itemstack:get_meta()
		if not meta:contains('recipe_kind') then sorcery.cookbook.setrecipe(itemstack) end
		local kind = meta:get_string('recipe_kind')
		local spec, w, h = retrieve_recipe(kind,meta:get_string('recipe_name'))
		minetest.show_formspec(user:get_player_name(), 'sorcery:recipe', string.format([[
			size[%f,%f]
			container[1,1] %s container_end[]
		]], w + 4,h + 2, 
				spec))
		return itemstack
	end;
})

dungeon_loot.register {
	name = 'sorcery:recipe';
	chance = 0.9;
	count = {1,7};
}

default.register_craft_metadata_copy('default:paper','sorcery:recipe')
-- this seems bugged; it doesn't like it when its item shows up in another
-- recipe. so we'll do it manually :/
-- default.register_craft_metadata_copy('default:book','sorcery:cookbook')

for i=1,8 do
	local rcp = {}
	for i=1,i do rcp[i] = 'sorcery:recipe' end
	rcp[#rcp+1]='default:book' minetest.register_craft {
		type = 'shapeless', recipe = rcp, output = 'sorcery:cookbook';
	}
	rcp[#rcp]='sorcery:cookbook' minetest.register_craft {
		type = 'shapeless', recipe = rcp, output = 'sorcery:cookbook';
	}
end

minetest.register_craft {
	type = 'shapeless';
	recipe = {
		'sorcery:cookbook';
		'default:book';
	};
	output = 'sorcery:cookbook';
};

-- erase cookbooks in the usual way
minetest.register_craft {
	type = 'shapeless';
	recipe = {
		'sorcery:cookbook';
		'bucket:bucket_water';
	};
	output = 'default:book';
	replacements = {
		{'bucket:bucket_water','bucket:bucket_empty'};
	};
};

local m = sorcery.lib.marshal
local encbook, decbook = m.transcoder {
	pages = m.g.array(8, m.g.struct {
		kind = m.t.str;
		name = m.t.str;
	})
}

local bookprops = function(stack)
	local meta = stack:get_meta()
	if meta:contains('cookbook') then
		return decbook(sorcery.lib.str.meta_dearmor(meta:get_string('cookbook'),true))
	else return {pages={}} end
end

sorcery.cookbook.get = bookprops
sorcery.cookbook.set = function(stack,props)
	local meta = stack:get_meta()
	meta:set_string('cookbook', sorcery.lib.str.meta_armor(encbook(props),true))
end

sorcery.cookbook.defaults = {
	indesc = desc_builtin;
	outdesc = desc_builtin;
	title = desc_builtin;
	props = props_builtin;
	pick = pick_builtin;
	find = find_builtin;
}

sorcery.cookbook.recfn = function(class,fn)
	local c = sorcery.cookbook.classes[class]
	if c[fn] then return c[fn] end
	return sorcery.cookbook.defaults[fn]
end

local bookform_ctx = {}
local bookform = function(stack,user)
	bookform_ctx[user:get_player_name()] = user:get_wield_index()

	local meta = stack:get_meta()
	local book = bookprops(stack)
	local pagect = math.ceil(#book.pages / constants.recipes_per_cookbook_page)
	local curpage = meta:contains("pagenr") and meta:get_int("pagenr") or 1

	local pgofs = (curpage - 1) * constants.recipes_per_cookbook_page
	local form = string.format([[
		formspec_version[3] 
		size[10,12]real_coordinates[true]
		box[0,0;10,1;#580C39FF]label[0.4,0.5;%s]
		style[pgind;border=false]
		style[pgind:hovered;content_offset=0,0]
		button[3,11;4,1;pgind;Page %u/%u]
	]], minetest.formspec_escape(meta:get_string('description')),
	    curpage, pagect)
		-- using an extremely dishonorable trick to fake centered text
	
	if curpage > 1      then form = form .. 'button[0,11;3,1;pageback;< Back]' end
	if curpage < pagect then form = form .. 'button[7,11;3,1;pagenext;Next >]' end

	local coords = {
		{0.85,1.3};
		{0.85,4.5};
		{0.85,7.7};
		-- {0,1.3}, {4, 1.3};
		-- {0,4.7}, {4, 4.7};
		-- {0,8.1}, {4, 8.1};
	}
	for i=pgofs,(pgofs + constants.recipes_per_cookbook_page-1) do
		local maxw, maxh = 3, 2
		if not book.pages[i+1] then break end
		local nr = 1+(i - pgofs)
		local x,y = coords[nr][1], coords[nr][2]
		local k = recipe_kinds[book.pages[i+1].kind]
		local ox,oy = maxw - k.w, maxh - k.h
		form = form .. string.format('container[%f,%f]%scontainer_end[]',(x+ox)-0.5,y,
			retrieve_recipe(book.pages[i+1].kind, book.pages[i+1].name, true))
	end

	minetest.show_formspec(user:get_player_name(), 'sorcery:cookbook', form)
end

minetest.register_craftitem('sorcery:cookbook', {
	description = 'Catalog';
	inventory_image = 'default_book_written.png';
	groups = { book = 1; flammable = 3 };
	on_use = function(stack,user)
		local book = bookprops(stack)
		if #book.pages == 0 then return nil end
		bookform(stack,user)
	end;
})

minetest.register_on_player_receive_fields(function(user,form,fields)
	if form ~= 'sorcery:cookbook' then return false end
	if fields.quit then
		bookform_ctx[user:get_player_name()] = nil
		return true
	end

	local idx = bookform_ctx[user:get_player_name()]
	if not idx then return true end

	local uinv = user:get_inventory()
	local stack = uinv:get_stack('main', idx)
	local book = bookprops(stack)
	local meta = stack:get_meta()

	local curpage = meta:contains("pagenr") and meta:get_int("pagenr") or 1
	local pagect = math.ceil(#book.pages / constants.recipes_per_cookbook_page)

	if curpage > 1 and fields.pageback then
		meta:set_int('pagenr', curpage - 1)
	elseif curpage < pagect and fields.pagenext then
		meta:set_int('pagenr', curpage + 1)
	end

	uinv:set_stack('main',idx,stack)
	bookform(stack,user)
end)

minetest.register_on_craft(function(stack,player,grid,inv)
	-- god this is messy. i'm sorry. minetest made me do it
	if stack:get_name() ~= 'sorcery:cookbook' then return nil end

	local oldbook
	local topic, onetopic = nil, true
	local recipes = {}
	local copybook = false
	local obindex
	for i,s in pairs(grid) do
		if s:get_name() == 'sorcery:recipe' then
			recipes[#recipes+1] = s
		elseif s:get_name() == 'default:book' then copybook = true
		elseif s:get_name() == 'sorcery:cookbook' then oldbook = s obindex = i end
	end

	if #recipes == 0 and copybook and oldbook then
		inv:set_stack('craft',obindex,oldbook)
		local newmeta = stack:get_meta()
		local copy = function(field)
			newmeta:set_string(field,oldbook:get_meta():get_string(field))
		end
		copy('cookbook') copy('description')
		newmeta:set_string('owner',player:get_player_name())
		return stack
	end

	oldbook = oldbook or stack
	local bookmeta = oldbook:get_meta()
	local newbook = not bookmeta:contains('cookbook')
	local book = bookprops(oldbook)

	for _,s in pairs(recipes) do
		local meta = s:get_meta()
		local kind, item
		if meta:contains('recipe_kind') then
			kind = meta:get_string('recipe_kind')
			item = meta:get_string('recipe_name')
		else item, kind = sorcery.cookbook.pickrecipe(kind) end
		book.pages[#book.pages + 1] = { name = item, kind = kind }
		if topic then
			if topic ~= kind then onetopic = false end
		else topic = kind end
	end

	if topic and newbook then
		if not onetopic then topic = nil end
		bookmeta:set_string('description',namebook(topic,player:get_player_name()))
		bookmeta:set_string('owner',player:get_player_name())
	end

	bookmeta:set_string('cookbook', sorcery.lib.str.meta_armor(encbook(book),true))
	return oldbook
end)

if minetest.get_modpath('books') then
	-- make our own placeable cookbook somehow
end