sorcery  runes.lua at [de7d27795b]

File data/runes.lua artifact 00d856eebe part of check-in de7d27795b


-- a rune is an abstract object created by a runeforge, which can be
-- applied to an amulet in order to imbue that amulet with unique
-- and fearsome powers. the specific spell depends on the stone the
-- rune is applied to, and not all runes can necessarily be applied
-- to all stones.

local L = sorcery.lib
local sparkle_region = function(s)
	s.spell.visual_subjects {
		amount = s.amt, time = s.time, -- attached = s;
		minpos = s.minpos;
		maxpos = s.maxpos;
		minvel = { x = -0.4, y = -0.2, z = -0.4 };
		maxvel = { x =  0.4, y =  0.2, z =  0.4 };
		minacc = { x = -0.5, y = -0.4, z = -0.5 };
		maxacc = { x =  0.5, y =  0.4, z =  0.5 };
		minexptime = 1.0*(s.length or 1), maxexptime = 2.0 * (s.length or 1);
		minsize = s.minsize, maxsize = s.maxsize, glow = 14;
		texture = (s.img or sorcery.vfx.glowspark(s.color)):render();
		animation = {
			type = 'vertical_frames';
			aspect_w = 16, aspect_h = 16;
			length = 0.1 + (s.length or 1)*2;
		};
	}
end
local sparkle = function(color, spell, amt,time,minsize,maxsize,sh)
	sparkle_region { spell = spell;
		amt = amt, time = time, color = color;
		minsize = minsize, maxsize = maxsize;
		minpos = { x = -0.3, y = -0.5, z = -0.3 };
		maxpos = { x =  0.3, y = sh*1.1, z = 0.3 };
	}
end
local sparktrail = function(fn,tgt,color,time)
	return (fn or minetest.add_particlespawner)({
		amount = 240, time = time or 1, attached = tgt;
		minpos = {x = -0.4, y = -0.5, z = -0.4};
		maxpos = {x =  0.4, y = tgt:get_properties().eye_height or 0.5, z =  0.4};
		minacc = {x =  0.0, y = 0.05, z =  0.0};
		maxacc = {x =  0.0, y = 0.15, z =  0.0};
		minexptime = 1.5, maxexptime = 5;
		minsize = 0.5, maxsize = 2.6, glow = 14;
		texture = sorcery.vfx.glowspark(color):render();
		animation = {
			type = 'vertical_frames', length = 5.1;
			aspect_w = 16, aspect_h = 16;
		};
	});
end

local teleport = function(ctx,subjects,delay,pos,color)
	if ctx.amulet.frame == 'tungsten' then delay = delay * 0.5 end
	color = color or sorcery.lib.color(29,205,247)
	local center = ctx.caster:get_pos()
	for _,sub in pairs(subjects) do
		local s = sub.ref
		local offset = vector.subtract(s:get_pos(), center)
		local pt = sorcery.lib.node.get_arrival_point(vector.add(pos,offset))
		if pt then
			-- minetest.sound_play('sorcery_stutter', {
			-- 	object = s, gain = 0.8;
			-- },true)
			local mydelay = sub.delay or (delay + math.random(-10,10)*.1);
			local sh = s:get_properties().eye_height
			local color = sub.color or color
			sorcery.lib.node.preload(pt,s)
			sorcery.spell.cast {
				name = 'sorcery:translocate';
				duration = mydelay;
				caster = ctx.caster;
				subjects = {{player=s,dest=sub.dest or pt}};
				timeline = {
					[0] = function(sp,_,timeleft)
						sparkle(color,sp,timeleft*100, timeleft, 0.3,1.3, sh)
						sp.windup = (sp.play_now{
							sound = 'sorcery_windup';
							where = 'subjects';
							gain = 0.4;
							fade = 1.5;
						})[1]
					end;
					[0.4] = function(sp,_,timeleft)
						sparkle(color,sp,timeleft*150, timeleft, 0.6,1.8, sh)
					end;
					[0.7] = function(sp,_,timeleft)
						sparkle(color,sp,timeleft*80, timeleft, 2,4, sh)
					end;
					[1] = function(sp)
						sp.silence(sp.windup)
						minetest.sound_play('sorcery_zap', { pos = pt, gain = 0.4 },true)
						minetest.sound_play('sorcery_zap', { pos = s:get_pos(), gain = 0.4 },true)
						sorcery.vfx.body_sparkle(nil,color:brighten(1.3),2,s:get_pos())
						s:set_pos(pt)
						sorcery.vfx.body_sparkle(s,color:darken(0.3),2)
					end;
				};
				sounds = {
					[0] = { sound = 'sorcery_stutter', pos = 'subjects' };
				};
			}
		end
	end
end

local purge = function(target)
	local h = target:get_properties().eye_height * 1.1
	minetest.add_particlespawner {
		time = 0.2, amount = math.random(200,250), attached = target;
		glow = 14, texture = sorcery.vfx.glowspark(sorcery.lib.color(156,255,10)):render();
		minpos = {x = -0.3, y = -0.5, z = -0.3};
		maxpos = {x =  0.3, y =  h,   z =  0.3};
		minvel = {x = -1.8, y = -1.8, z = -1.8};
		maxvel = {x =  1.8, y =  1.8, z =  1.8};
		minsize = 0.2, maxsize = 5;
		animation = {
			type = 'vertical_frames', length = 4.1;
			aspect_w = 16, aspect_h = 16;
		};
		minexptime = 2, maxexptime = 4;
	}
	minetest.sound_play('sorcery_disjoin',{object=target},true)
	sorcery.spell.disjoin{target=target}
end

return {
	translocate = {
		name = 'Translocate';
		tone = {0,235,233};
		minpower = 2;
		rarity = 8;
		amulets = {
			amethyst = {
				name = 'Joining';
				desc = 'Give this amulet to another and with a snap of their fingers they can arrive safely at your side from anywhere in the world — though returning whence they came may be a more difficult matter';
				apply = function(ctx)
					local maker = ctx.user:get_player_name()
					ctx.meta:set_string('rune_join_target',maker)
				end;
				remove = function(ctx) ctx.meta:set_string('rune_join_target','') end;
				cast = function(ctx)
					local target = minetest.get_player_by_name(ctx.meta:get_string('rune_join_target'))
					if not target then return false end

					local subjects if ctx.amulet.frame == 'cobalt' then
						if ctx.target.type ~= 'object' then return false end
						subjects = {{ref=ctx.target.ref}}
					elseif ctx.amulet.frame == 'iridium' then
						subjects = {}
						for _,o in pairs(minetest.get_objects_inside_radius(ctx.caster:get_pos(), ctx.stats.power)) do
							subjects[#subjects+1] = {player = o}
						end
					else subjects = {{ref=ctx.caster}} end

					local delay = math.max(5,11 - ctx.stats.power) + 2.3*(math.random()*2-1)
					local color = sorcery.lib.color(117,38,237)
					teleport(ctx,subjects,delay,target:get_pos(),color)
					if ctx.amulet.frame == 'gold' then
						teleport(ctx,{{ref=target}},delay,ctx.caster:get_pos())
					else
						ctx.sparkle = false
					end
				end;
				frame = {
					tungsten = {
						name = 'Quick Joining';
						desc = 'Give this amulet to another and they can arrive safely at your side almost instantaneously from anywhere at all in the world — though returning whence they came may be a more difficult matter';
					};
					gold = {
						name = 'Exchange';
						desc = 'Give this amulet to another and they will be able to trade places with you no matter where in the world each of you might be.'; 
					};
					cobalt = {
						name = 'Sending';
						desc = 'Give this amulet to another and by wielding this amulet against another they will be able to transport them instantly to your side';
					};
					iridium = {
						name = 'Arrival';
						desc = "Give this amulet to another and they will be able to arrive at your side in a flash from anywhere in the world, carrying others with them in the spell's grip";
					};
				};
			};
			sapphire = {
				name = 'Return';
				desc = 'Use this amulet once to bind it to a particular place, then discharge its spell to translocate yourself back to that point from anywhere in the world.';
				remove = function(ctx)
					ctx.meta:set_string('rune_return_dest','')
				end;
				cast = function(ctx)
					if not ctx.meta:contains('rune_return_dest') then
						local pos = ctx.caster:get_pos()
						ctx.meta:set_string('rune_return_dest',minetest.pos_to_string(pos))
						return true -- play effects but do not break spell
					else
						if ctx.caster:get_attach() ~= nil then return false end
						local pos = minetest.string_to_pos(ctx.meta:get_string('rune_return_dest'))
						ctx.meta:set_string('rune_return_dest','')
						local subjects = { ctx.caster }
						local center = ctx.caster:get_pos()
						ctx.sparkle = false
						local delay = math.max(3,10 - ctx.stats.power) + 3*(math.random()*2-1)
						teleport(ctx,{{ref=ctx.caster}},delay,pos)
					end
				end;
				frame = {
					tungsten = {
						name = 'Quick Return';
						desc = 'Use this amulet once to bind it to a particular place, then discharge its spell to translocate yourself rapidly back to that point from anywhere in the world.';
					};
					iridium = {
						name = 'Mass Return';
						desc = 'Use this amulet once to bind it to a particular place, then carry yourself and everyone around you back to that point in a flash simply by using it again';
					};
				};
			};
			emerald = {
				name = 'Banishment';
				desc = 'Use this amulet once to bind it to a particular point in the world, then wield it against a foe to whisk them away immediately to your chosen prison';
				frame = {
					iridium = {
						name = 'Mass Banishment';
						desc = 'Use this amulet once to bind it to a particular point in the world, then use it again to seize up everyone surrounding you in the grip of a fearsome magic that will deport them all in the blink of an eye to whatever destination you have chosen';
					};
				};
			};
			ruby = minetest.get_modpath('beds') and {
				name = 'Escape';
				desc = 'Immediately transport yourself out of a dangerous situation back to the last place you slept, before anyone has time to net you in a disjunction';
				mingrade = 4;
				cast = function(ctx)
					-- if not beds.spawns then beds.read_spawns() end
					local subjects = {ctx.caster}
					for _,s in pairs(subjects) do
						local spp = beds.spawn[ctx.caster:get_player_name()]
						if spp then
							local oldpos = s:get_pos()
							local jump = function()
								minetest.sound_play('sorcery_splunch', {pos=oldpos}, true)
								sorcery.vfx.body_sparkle(nil,sorcery.lib.color(244,38,131),2,oldpos)
								s:set_pos(spp)
								minetest.sound_play('sorcery_splunch', {pos=spp}, true)
								sorcery.vfx.body_sparkle(nil,sorcery.lib.color(244,38,89),2,spp)
							end
							if ctx.amulet.frame == 'cobalt' then
								sorcery.spell.cast {
									name = 'sorcery:escape';
									caster = s;
									duration = random() * 0.4 + 0.3;
									timeline = {
										[0] = function()
											sorcery.vfx.imbue(sorcery.lib.color(244,38,131), s, 1.3)
										end;
										[1] = function(sp)
											local radius = 6 * ctx.stats.power
											local center = sp.caster:get_pos()
											local targets = minetest.get_objects_inside_radius(center, radius)
											jump()
											-- TODO: shockwave visuals
											for _,o in pairs(targets) do
												if not o:get_armor_groups().immortal then
													local distance = vector.distance(o:get_pos(), center)
													local dmg = (7 * ctx.stats.power) * (distance / radius)
													minetest.punch(ctx.caster, 1.0, {
														full_punch_interval = 1.0;
														damage_groups = { fleshy = dmg };
													}, vector.direction(o:get_pos(), center));
												end
											end 
										end;
									}
								}
							else jump() end
						end
						-- TODO decide what happens to the people who don't have
						-- respawn points already set
					end
				end;
				frame = {
					cobalt = {
						name = 'Vengeful Exit';
						desc = 'Translocate away to the safety of your boudoir with a fearsome blast of dangerous radiance that will send bodies flying and deal heavy damage to those nearby';
					};
					iridium = {
						name = 'Mass Escape';
						desc = 'Break up even the fiercest of quarrels by transporting yourself and everyone around you out of harms\' way and immediately back to the last place each slept';
					};
				};
			};
			diamond = {
				name = 'Elevation';
				desc = 'Lift yourself and everything around you high up into the sky';
				cast = function(ctx)
					local center = ctx.caster:get_pos()
					local up = ((ctx.stats.power * 7) + math.random(6,17)) * (math.random() * 0.4 + 0.4)
					if center.y > 0 then up = up + center.y end
					local newcenter = vector.new(center.x,up,center.z)
					if not sorcery.lib.node.get_arrival_point(newcenter) then return false end
					sorcery.lib.node.preload(newcenter,ctx.caster)
					local jmpcolor = sorcery.lib.color(0,255,144)

					if not ctx.amulet.frame == 'iridium' then
						local where = vector.offset(center,0,1,0)
						repeat local ok, nx = minetest.line_of_sight(where, newcenter)
							if ok then break end
							if minetest.get_node_or_nil(nx) == nil then
								minetest.load_area(nx)
								where = nx -- save some time
							else return false end
						until false
					end
					local lift = function(n)
						local dest = vector.new(n.pos.x, up + n.h, n.pos.z)
						if sorcery.lib.node.is_clear(dest) then
							minetest.set_node(dest, minetest.get_node(n.pos))
							minetest.get_meta(dest):from_table(minetest.get_meta(n.pos):to_table())
							if math.random(5) == 1 then
								minetest.set_node(n.pos, {name='sorcery:air_flash_' .. tostring(math.random(10))})
							else minetest.remove_node(n.pos) end
							local obs = minetest.get_objects_inside_radius(n.pos, 1.5)
							if obs then for _,o in pairs(obs) do
								local pt = sorcery.lib.node.get_arrival_point(vector.add(dest, vector.subtract(o:get_pos(),n.pos))) 
								if pt then
									o:set_pos(pt)
									sorcery.vfx.body_sparkle(o,jmpcolor:darken(0.3),2)
								end
							end end
							return true
						else
							return false
						end
					end
					local nodes,sparkles,tmap = {},{},{}
					local r = math.ceil((ctx.stats.power * 0.1) * 8 + 3)
					for x = -r,r do -- lazy hack to select a sphere
					for z = -r,r do
						local col = {}
						for y = -r,r do
							local ofs = vector.new(x,y,z)
							if sorcery.lib.math.vdcomp(r,ofs) <= 1 then
								local pos = vector.add(center, ofs)
								if sorcery.lib.node.is_air(pos) then
									if y > 0 then
										sparkles[#sparkles+1] = pos
										break -- levitation is a sin
									end
								else
									nodes[#nodes+1] = {pos=pos, h=y}
									col[#col+1] = {pos=pos, h=y}
								end
							end
						end
						if #col > 0 then
							local seq = math.floor(math.sqrt((x^2) + (z^2)))
							-- TODO find a way to optimise this shitshow
							if tmap[seq]
								then tmap[seq][#(tmap[seq])+1] = col
								else tmap[seq] = {col}
							end
						end
					end end

					-- for _,n in pairs(nodes) do
					-- 	local dest = vector.new(n.pos.x, up + n.h, n.pos.z)
					-- 	if sorcery.lib.node.is_clear(dest) then
					-- 		minetest.set_node(dest, minetest.get_node(n.pos))
					-- 		minetest.get_meta(dest):from_table(minetest.get_meta(n.pos):to_table())
					-- 		if math.random(5) == 1 then
					-- 			minetest.set_node(n.pos, {name='sorcery:air_flash_' .. tostring(math.random(10))})
					-- 		else minetest.remove_node(n.pos) end
					-- 	end
					-- end
					local timeline, sounds = {
						[0] = function(s)
							-- sorcery.vfx.imbue(jmpcolor,s.caster,1)
						end;
					}, {};
					local time = 0;
					for i=0,#tmap do
						local cols = tmap[i]
						if cols ~= nil then
							time = time + math.random()*0.2 + 0.1
							local wh = {whence=0,secs=2+time}
							timeline[wh] = function(sp)
								for _,col in pairs(cols) do
									for _,n in pairs(col) do lift(n) end
								end
							end
							sounds[wh] = {
								sound = 'sorcery_zap';
								gain = math.random() + 0.1;
								where = cols[1][1].pos;
							}
						end
					end
					sorcery.spell.cast {
						name = 'sorcery:elevate';
						caster = ctx.caster;
						anchor = center, radius = r;
						duration = 2 + time;
						timeline = timeline, sounds = sounds;
					}
				end;
				frame = {
					iridium = {
						name = 'Ascension';
						desc = 'Transport yourself and your surroundings high into the heavens, even if you are deep in the bowels of the earth';
					};
				};
			};
		};
	};
	disjoin = {
		name = 'Disjoin';
		tone = {159,235,0};
		minpower = 4;
		rarity = 10;
		amulets = {
			sapphire = {
				name = 'Unsealing';
				desc = 'Wielding this amulet, a touch of your hand will unravel even the mightiest protective magics, leaving doors unsealed and walls free to tear down';
				sound = 'sorcery_disjoin';
				cast = function(ctx)
					if ctx.target.type ~= 'node' then return false end
					local r = 2 + math.floor(ctx.stats.power / 2)
					local cast = false
					for x = -r,r do
					for y = -r,r do
					for z = -r,r do
						if x^2 + y^2 + z^2 <= r^2 then
							local pos = vector.offset(ctx.target.under, x,y,z)
							local abv = vector.offset(ctx.target.above, x,y,z)
							local meta = minetest.get_meta(pos)
							if meta:contains('sorcery_seal_mode') then
								meta:set_string('sorcery_seal_mode', '')
								meta:set_string('sorcery_wand_key', '')
								meta:set_string('owner', '')
								sorcery.vfx.enchantment_sparkle({under=pos,above=abv}, sorcery.lib.color(12,38,255))
								cast = true
							end
						end
					end end end
					if cast then
						minetest.sound_play('sorcery_disjoin',{object=target},true)
					end
					return cast
				end;
			};
			amethyst = {
				name = 'Purging';
				desc = 'Free yourself from the grip of any malicious spellwork with a snap of your fingers — interrupting all of your own active spells in the process, including impending translocations';
				sound = 'sorcery_disjoin';
				cast = function(ctx) purge(ctx.caster) end;
			};
			emerald = {
				name = 'Disjunction Field';
				desc = 'Render an area totally opaque to spellwork for a period of time, disrupting any existing spells and preventing further spellcasting therein';
				sound = 'sorcery_disjoin';
			};
			ruby = {
				name = 'Disjunction';
				desc = 'Wield this amulet against a spellcaster to disrupt and abort all their spells in progress, perhaps to trap a foe intent on translocating away, or unleash its force upon the victim of a malign hex to free them from its clutches';
				mingrade = 3;
				sound = 'sorcery_disjoin';
				cast = function(ctx)
					if ctx.target.type == 'object'
						then purge(ctx.target.ref)
						else return false
					end
				end;
				frame = {
					iridium = {
						name = 'Nullification';
						mingrade = 5;
						desc = 'Not only will your victim\'s spells be nullified, but all enchanted objects they carry will be stripped of their power — or possibly even destroyed outright';
					};
				};
			};
			luxite = {
				name = 'Disjunctive Aura';
				desc = 'For a time, all magic undertaken in your vicinity will fail totally — including your own';
				sound = 'sorcery_disjoin';
				cast = function(ctx)
					local h = ctx.heading.eyeheight*1.1
					sorcery.spell.cast {
						name = 'sorcery:disjunctive-aura';
						caster = ctx.caster, attach = 'caster';
						subjects = {{player=ctx.caster}};
						disjunction = true, range = 4 + ctx.stats.power;
						duration = 10 + ctx.stats.power * 3;
						timeline = {
							[0] = function(s,_,tl)
								local ttns = 0.8
								local vel = s.range / ttns
								s.visual_subjects {
									amount = 300, time = ttns, glow = 14;
									texture = sorcery.lib.image('sorcery_sputter.png'):glow(sorcery.lib.color(160,255,80)):render();
									minpos = { x = -0.0, y = h*0.5,z = -0.0 };
									maxpos = { x =  0.0, y = h*0.5,z =  0.0 };
									minvel = { x = -vel, y = -0.0, z = -vel };
									maxvel = { x =  vel, y =  0.0, z =  vel };
									minacc = { x = -0.2, y = -0.0, z = -0.2 };
									maxacc = { x =  0.2, y =  0.0, z =  0.2 };
									minexptime = ttns, maxexptime = ttns * 2;
									minsize = 0.2, maxsize = 4.5;
									animation = {
										type = 'vertical_frames', length = 0.1 + ttns*2;
										aspect_w = 16, aspect_h = 16;
									}
								}
							end;
							[{whence=0,secs=0.8}] = function(s,te,tl)
								local range = s.range
								sparkle_region {
									spell = s, amt = 150*tl, time = tl;
									minsize = 1, maxsize = 8.4;
									minpos = { x = 0-range, y = -0.5, z = 0-range };
									maxpos = { x =   range, y = h,    z =   range };
									img = sorcery.lib.image('sorcery_flicker.png'):glow(sorcery.lib.color(120,255,30));
								}
							end;
						};
						sounds = {
							-- [0.00] = {sound='sorcery_disjoin',   where='caster'};
							[{whence=0,secs=0.8}] = {
								sound='sorcery_disjoin_bg', where='subjects';
								gain=0.5, stop = {whence=1,secs=-1.5}
							};
							[1.00] = {sound='sorcery_powerdown', where='caster'};
						};
					}
				end
			};
			diamond = {
				name = 'Mundanity';
				desc = 'Strip away the effects of all active potions and spells in your immediate vicinity, leaving adversaries without their magicks to enhance and protect them, and allies free of any curses they may be hobbled by -- and, of course, vice versa';
				sound = 'sorcery_disjoin';
				cast = function(ctx)
					local where = ctx.caster:get_pos()
					local what = minetest.get_objects_inside_radius(where, 3 + (2*ctx.stats.power))
					local who = {}
					local pfac = math.min(1, ctx.stats.power/8)
					for _, w in pairs(what) do
						if w:is_player() and w ~= ctx.caster then
							who[#who+1] = {player = w}
						end
					end
					if not next(who) then return false end

					local timeline = {}
					local casttime = 4 - (3 * pfac)
					local tf = casttime / #who

					local h = ctx.heading.eyeheight*1.1
					sorcery.spell.cast {
						name = 'sorcery:mundanity';
						caster = ctx.caster;
						attach = 'caster';
						subjects = who;
						_aff_idx = 1;
						_ntgt = #who;
						duration = 3 + casttime;
						timeline = {
							[0] = function(s,te,tl)
								s.visual_caster {
									amount = 100 * s.duration, time = s.duration, glow = 14;
									texture = sorcery.lib.image('sorcery_sputter.png'):glow(sorcery.lib.color(160,255,80)):render();
									minpos = { x = -0.1, y = -0.5,z = -0.1 };
									maxpos = { x =  0.1, y =  h*0.5,z =  0.1 };
									minvel = { x = -0.0, y = -8.0, z = -0.0 };
									maxvel = { x =  0.0, y =  8.0, z =  0.0 };
									minacc = { x = -0.2, y = -0.0, z = -0.2 };
									maxacc = { x =  0.2, y = -0.2, z =  0.2 };
									minexptime = 3, maxexptime = 3;
									minsize = 0.2, maxsize = 3.5;
									animation = {
										type = 'vertical_frames', length = (21/6) + 0.1;
										aspect_w = 16, aspect_h = 16;
									}
								}
							end;
							[0.3] = function(s,te,tl)
								s.visual_caster {
									amount = 100 * s.duration, time = s.duration, glow = 14;
									texture = sorcery.lib.image('sorcery_sputter.png'):glow(sorcery.lib.color(160,255,80)):render();
									minpos = { x = -0.1, y =  4.0, z = -0.1 };
									maxpos = { x =  0.1, y =  4.2, z =  0.1 };
									minvel = { x = -5.0, y = -0.5, z = -5.0 };
									maxvel = { x =  5.0, y =  0.5, z =  5.0 };
									minacc = { x = -0.4, y =  1.0, z = -0.4 };
									maxacc = { x =  0.4, y = -1.0, z =  0.4 };
									minexptime = 6, maxexptime = 6;
									minsize = 0.2, maxsize = 1.4;
									animation = {
										type = 'vertical_frames', length = (21/24) + 0.1;
										aspect_w = 16, aspect_h = 16;
									};
								}
							end;
						};
						intervals = {
							{period = tf; after = {secs=1.2}; fn = function(c)
								local sub = c.spell.subjects[c.spell._aff_idx] 
								if sub then 
									c.spell.visual {
										amount = 100, time = 2, glow = 14;
										attached = c.spell.subjects[c.spell._aff_idx].player;
										texture = sorcery.lib.image('sorcery_sputter.png'):glow(sorcery.lib.color(160,255,80)):render();
										minpos = { x = -0.3, y =  7.3, z = -0.3 };
										maxpos = { x =  0.3, y =  5.7, z =  0.3 };
										minvel = { x = -0.0, y = -7.5, z = -0.0 };
										maxvel = { x =  0.0, y = -8.5, z =  0.0 };
										minacc = { x = -0.4, y =  1.0, z = -0.4 };
										maxacc = { x =  0.4, y = -2.0, z =  0.4 };
										minexptime = 6, maxexptime = 6;
										minsize = 3, maxsize = 7;
										animation = {
											type = 'vertical_frames', length = (21/6) + 0.1;
											aspect_w = 16, aspect_h = 16;
										};
									}
									-- if c.spell._aff_idx > c.spell._ntgt then return false end
								end
							end};
							{period = tf; after = {secs=2.1}; fn = function(c)
								local sub = c.spell.subjects[c.spell._aff_idx] 
								if sub then purge(sub.player) end
								c.spell._aff_idx = c.spell._aff_idx + 1
								-- if c.spell._aff_idx > c.spell._ntgt then return false end
							end};
						};
					}
				end;
				frame = {
					iridium = {
						name = 'Spellshatter';
						desc = 'Blast out a tidal wave of anti-magic that will nullify active spells, but also disenchant or destroy all magical items in range of its violently mundane grip';
						sound = 'sorcery_disjoin';
						cast = function(ctx)
							local where = ctx.caster:get_pos()
							local radius = 3 + (2*ctx.stats.power)
							local what = minetest.get_objects_inside_radius(where, radius)
							local who = {}
							local pfac = math.min(1, ctx.stats.power/8)
							for _, w in pairs(what) do
								if w:is_player() and w ~= ctx.caster then
									who[#who+1] = {player = w}
								end
							end
							--if not next(who) then return false end
							local h = ctx.heading.eyeheight*1.1
							local tex = sorcery.lib.image('sorcery_sputter.png'):glow(sorcery.lib.color(160,255,80)):render();
							local epicenter = ctx.caster:get_pos() + vector.new(0, h/2, 0)
							local maxima = vector.new(radius, radius, radius)
							local potential_targets = minetest.find_nodes_in_area(epicenter - maxima, epicenter + maxima, {'group:sorcery_magical', 'group:sorcery_magitech'})
							local wreck = {}
							for _, pos in pairs(potential_targets) do
								if sorcery.lib.math.vdcomp(radius,epicenter,pos) then
									wreck[#wreck+1] = pos
								end
							end
							sorcery.spell.cast {
								name = 'sorcery:spellshatter';
								caster = ctx.caster;
								anchor = epicenter;
								--disjunction = true;
								--range = radius;
								duration = 10;
								timeline = {
									[0] = function(s)
										s.visual_caster {
											texture = tex;
											amount = 100, time = 0.2;
											minpos = { x = -0.1, y = -0.5, z = -0.1 };
											maxpos = { x =  0.1, y =  h,   z =  0.1 };
											minvel = { x = -10.0, y = -0.0, z = -10.0 };
											maxvel = { x =  10.0, y =  0.0, z =  10.0 };
											minexptime = 3, maxexptime = 3;
											minsize = 5, maxsize = 8;
											animation = {
												type = 'vertical_frames', length = (21/6) + 0.1;
												aspect_w = 16, aspect_h = 16;
											}
										}
										s.visual_caster {
											texture = tex;
											amount = 5000, time = 2;
											minpos = { x = -0.1, y = -0.5, z = -0.1 };
											maxpos = { x =  0.1, y =  h,   z =  0.1 };
											minvel = { x = -10.0, y = -0.0, z = -10.0 };
											maxvel = { x =  10.0, y =  0.0, z =  10.0 };
											minexptime = 3, maxexptime = 3;
											minsize = 0.7, maxsize = 2;
											animation = {
												type = 'vertical_frames', length = (21/6) + 0.1;
												aspect_w = 16, aspect_h = 16;
											}
										}
									end;
								};
								sounds = {
									[{whence=0,secs=0.8}] = {
										sound='sorcery_disjoin_bg', where=where;
										gain=0.5, stop = {whence=1,secs=-0.6}
									};
								};
								intervals = {
									{period = 0.1, after = 0.15, fn = function(c)
										for i = 1,80 do
											local life = 0.2 + math.random() * 2
											local dir = vector.new(math.random()-0.5,0,math.random()-0.5):normalize()
											local pos = epicenter + (dir * (math.random()*radius))
											minetest.add_particle {
												texture = tex;
												pos = pos;
												expirationtime = life;
												velocity = {x = 0, y = math.random() * 0.3 + 0.1, z = 0};
												size = 0.4 + math.random() * 3;
												glow = 14;
												animation = {
													type = 'vertical_frames', length = math.random(1,3)/10;
													aspect_w = 16, aspect_h = 16;
												}
											}
										end
										if next(wreck) then
											local k,p = sorcery.lib.tbl.pick(wreck)

											minetest.add_particle {
												texture = L.image('sorcery_sparking.png'):glow(L.color(0,255,0)):render();
												pos = vector.offset(p,math.random(),math.random(),math.random());
												expirationtime = 1;
												size = 5 + math.random() * 4;
												glow = 14;
												animation = {
													type = 'vertical_frames', length = 0.3;
													aspect_w = 64, aspect_h = 64;
												}
											}
											minetest.add_particle {
												texture = L.image('sorcery_crackle.png'):glow(L.color(0,255,0)):render();
												pos = vector.offset(p,math.random(),math.random(),math.random());
												expirationtime = 1;
												size = 4 + math.random() * 6;
												glow = 14;
												animation = {
													type = 'vertical_frames', length = 1.1;
													aspect_w = 64, aspect_h = 64;
												}
											}
											if math.random(1,7) == 1 then
												minetest.sound_play('sorcery_' .. (math.random(1,2)==1 and 'rip' or 'crunch'), {
													pos = p;
													gain = math.random(5,13)*0.1;
												}, true)
												minetest.after(0.2, function() minetest.remove_node(p) end)
												sorcery.vfx.show {
													amount = 120, time = 0.3;
													kind = 'flicker', color = L.color(80,255,10);--(255,12,0);
													pos = p, radius = 0.4;
													velrange = 1, accrange = vector.new(0.0,0.2,0.0);
													minsize = 5, maxsize = 20;
													life = 0.6, varylife = 0.1;
												}
												sorcery.vfx.show {
													amount = 80, time = 0.2;
													minsize = 0.1, maxsize = 0.8;
													pos = p, radius = 0.6;
													node = minetest.get_node(p);
													vel = vector.new(0,7,0);
													velrange = vector.new(2, 4, 2);
													acc = vector.new(0,-9,0);
													life = 3, varylife = 0.4;
												}
												-- minetest.add_particlespawner {
												-- 	amount = 60, time = 0.2;
												-- 	texture = L.image('sorcery_flicker.png'):glow():render();
												-- 	minpos = vector.offset(p, -0.5, -0.5, -0.5);
												-- 	maxpos = vector.offset(p,  0.5,  0.5,  0.5);
												-- 	minacc = vector.new(0.0, -0.2, 0.0);
												-- 	maxacc = vector.new(0.0,  0.2, 0.0);
												-- 	minvel = vector.new(-1,-1,-1);
												-- 	maxvel = vector.new(1,1,1);
												-- 	minsize = 0.5, maxsize = 4;
												-- 	minexptime = 0.6, maxexptime = 0.7;
												-- 	animation = {
												-- 		type = 'vertical_frames';
												-- 		aspect_w = 16, aspect_h = 16;
												-- 		length = 0.8;
												-- 	}
												-- }
												table.remove(wreck,k)
											end
										end
									end};
								};
							}
						end;
					};
				};
			};
		}
	};
	repulse = {
		name = 'Repulse';
		tone = {0,180,235};
		minpower = 1;
		rarity = 7;
		amulets = {
			amethyst = {
				name = 'Hurling';
				desc = 'Wielding this amulet, a mere flick of your fingers will lift any target of your choice bodily into the air and press upon them with tremendous repulsive force, throwing them like a hapless ragdoll out of your path';
				cast = function(ctx)
					if not (ctx.target and ctx.target.type == 'object') then return false end
					local tgt = ctx.target.ref
					local line = vector.subtract(ctx.caster:get_pos(), tgt:get_pos())
					-- direction vector from target to caster
					local dir,mag = sorcery.lib.math.vsep(line)
					if mag > 6 then return false end -- no cheating!
					local force = 20 + (ctx.stats.power * 2.5)
					minetest.sound_play('sorcery_slide',{pos=tgt:get_pos()},true)
					local immortal = 0
					-- if tgt and tgt.get_armor_groups then
						immortal = tgt:get_armor_groups().immortal or 0
					-- end
					if minetest.is_player(tgt) or immortal == 0 then
						tgt:punch(ctx.caster, 1, {
							full_punch_interval = 1;
							damage_groups = { fleshy = force / 10 };
						})
					end
					sparktrail(nil,tgt,sorcery.lib.color(101,226,255))
					if dir.y > 0 then dir.y = 0 end -- spell always lifts
					dir = vector.add(dir, {x=0,z=0,y=-0.25})
					local vel = vector.multiply(dir,0-force)
					tgt:add_velocity(vel)
				end;
			};
			ruby = {
				name = 'Liftoff';
				desc = 'Lift yourself high into the air with a blast of violent repulsive force against the ground, and drift down safely to a position of your choice';
				cast = function(ctx)
					local power = 14 * (1+(ctx.stats.power * 0.2))
					minetest.sound_play('sorcery_hurl',{object=ctx.caster},true)

					local oldsp = sorcery.spell.ensorcelled(ctx.caster, 'sorcery:liftoff')
					if oldsp then oldsp:cancel() end

					sorcery.spell.cast {
						name = 'sorcery:liftoff';
						caster = ctx.caster;
						subjects = {{player=ctx.caster}};
						duration = power * 0.30;
						timeline = {
							[0] = function(s,_,tl)
								sparktrail(s.visual_subjects,ctx.caster,sorcery.lib.color(255,252,93))
								ctx.caster:add_velocity{y=power*1.2;x=0,z=0}
							end;
							[{whence=0, secs=1}] = function(s)
								s.affect {
									duration = power * 0.50;
									raise = 0.5;
									-- fall = (power * 0.25) * 0.3;
									impacts = {
										gravity = 0.2;
									};
								}
							end;
						};
						intervals = {
							{period = 0.2, after = {whence=0, secs=2}; fn = function(c)
							-- return gravity to normal once they touch down
								for si,sub in pairs(c.spell.subjects) do
									local p = sub.player:get_pos()
									for i=1,3 do
										local sum = vector.offset(p,0,-i,0)
										if not sorcery.lib.node.is_air(sum) then
											c.spell.release_subject(si)
											if #c.spell.subjects == 0 then
												return false
											end
											break
										end
									end
								end
							end};
						};
					}
				end;
			};
			sapphire = {
				name = 'Flinging';
				desc = 'Toss an enemy violently into the air, and allow the inevitable impact to do your dirty work for you';
				cast = function(ctx)
					if not (ctx.target and ctx.target.type == 'object') then return false end
					local tgt = ctx.target.ref
					local power = 16 * (1+(ctx.stats.power * 0.2))
					minetest.sound_play('sorcery_hurl',{object=ctx.caster},true)
					sorcery.spell.cast {
						name = 'sorcery:flinging';
						caster = ctx.caster;
						subjects = {{player=tgt}};
						duration = 4;
						timeline = {
							[0] = function(s,_,tl)
								for _,sub in pairs(s.subjects) do
									sorcery.vfx.imbue(function() return
										sorcery.lib.color {
											hue = math.random(41,63);
											saturation = 100;
											luminosity = 0.5 + math.random()*0.3;
										}
									end, sub.player)
								end
							end;
							[{whence=0, secs=1}] = function(s,te,tl)
								sparktrail(s.visual_subjects,ctx.caster,sorcery.lib.color(255,252,93))
								for _,sub in pairs(s.subjects) do
									sub.player:add_velocity{y=power;x=0,z=0}
								end
							end;
							[1] = (ctx.amulet.frame == 'cobalt') and function(s,te,tl)
								-- TODO add visuals
								for _,sub in pairs(s.subjects) do
									sub.player:add_velocity{y=-power*2;x=0,z=0}
								end
							end or nil;
						};
						sounds = {
							[0.3] = {
								sound = 'sorcery_slide';
								where = 'subjects';
								ephemeral = true;
							};
							[1] = (ctx.amulet.frame == 'cobalt') and {
								sound = 'sorcery_hurl';
								where = 'subjects';
								ephemeral = true;
							} or nil;
						};
					};
				end;
				frame = {
					cobalt = {
						name = 'Crushing';
						desc = 'Toss an enemy violently into the air, then bring them crashing down to earth with bone-shattering force';
					};
					iridium = {
						name = 'Mass Flinging';
						desc = 'Send everyone around you hurtling into the sky, and allow the inevitable impact to do your dirty work for you';
					};
				};
			};
			emerald = {
				name = 'Shockwave';
				desc = 'Let loose a stream of concussive force that slams into everything in your path and sends them hurtling away from you';
			};
			luxite = {
				name = 'Repulsive Aura';
				desc = 'For a period of time, anyone who approaches you will be violently thrust aside';
			};
			diamond = {
				name = 'Blastwave';
				desc = 'Unleash a tidal wave of force in every direction, blasting friends and foes alike away from you with enough violence to sprain and fracture bone';
			};
		};
	};
	obliterate = {
		name = 'Obliterate';
		tone = {255,0,10};
		minpower = 4;
		rarity = 10;
		amulets = {
			amethyst = {
				name = 'Sapping';
				desc = 'Punch a hole in enemy fortifications big enough to slip through but small enough to avoid immediate attention';
			};
			ruby = {
				name = 'Shattering';
				desc = 'Tear a violent wound in the land with the destructive force of this amulet';
			};
			emerald = {
				name = 'Detonate';
				desc = 'Wielding this amulet, you can loose an extraordinarily powerful bolt of flame from your fingertips that will explode violently on impact, wreaking total havoc wherever it lands';
				cast = function(ctx)
					local speed = 40
					local radius = math.random(math.floor(ctx.stats.power*0.5),math.ceil(ctx.stats.power))
					local heading = ctx.heading
					heading.pos.y = heading.pos.y + heading.eyeheight*0.9
					local vel = vector.multiply(heading.yaw,speed)
					local bolt = minetest.add_entity(vector.add(heading.pos,vector.multiply(heading.yaw,2.5)),'sorcery:spell_projectile_flamebolt')
					bolt:set_rotation(heading.yaw)
					bolt:get_luaentity()._blastradius = radius
					bolt:set_velocity(vel)
				end;
			};
			luxite = {
				name = 'Cataclysmic Aura';
				desc = 'A storm of destructive force rages about you as you stand untouched, the master of its voracious dark energies';
			};
			mese = {
				name = 'Cataclysm';
				desc = 'Use this amulet once to pick a target, then visit devastation upon it from afar whenever you so will with a mere snap of your fingers';
			};
			diamond = {
				name = 'Killing';
				mingrade = 4;
				desc = 'Wield this amulet against a foe to instantly snuff the life out of their mortal form, regardless of their physical protections.';
				cast = function(ctx)
					if not (ctx.target and ctx.target.type == 'object') then return false end
					local tgt = ctx.target.ref
					if not minetest.is_player(obj) then return false end
					local tgth = tgt:get_properties().eye_height
					sorcery.vfx.bloodburst(vector.add(tgt:get_pos(),{x=0,y=tgth/2,z=0}),20)
					minetest.sound_play('sorcery_bloody_burst', { pos = pos, gain = 1.5 })
					tgt:set_hp(0)
				end;
				frame = {
					iridium = {
						name = 'Massacre';
						desc = "Unleash the dark and wicked force that lurks within this fell amulet to instantaneously slay all those who surround you, friend and foe alike";
					};
				};
			};
		};
	};
	excavate = {
		name = 'Excavate';
		tone = {0,68,235};
		minpower = 2;
		rarity = 9;
		amulets = {
			luxite = {
				name = 'Stonestride';
				desc = 'Rock walls will open up before you when you brandish this amulet before them, closing up again behind you without leaving a trace of your passage';
			};
			sapphire = {
				name = 'Tunnelling';
				desc = 'Carve a long tunnel ahead of you into the rock and dirt';
				cast = function(ctx)
					if ctx.target.type ~= 'node' then return false end
					local allowed = {
						['default:stone'] = true;
						['default:desert_stone'] = true;
						['default:dirt'] = true;
						['default:gravel'] = true;
					}
					if allowed[minetest.get_node(ctx.target.under).name] ~= true then
						return false
					end
					local timeline,sounds = {}, {}
					local tunnel_depth = math.random(5,9) * ctx.stats.power
					local cname = ctx.caster:get_player_name()
					local cut = function(step,s,te,tl)
						local smash = function(pos)
							if not allowed[minetest.get_node(pos).name] then return end
							if minetest.is_protected(pos, cname) then return end
							s.visual {
								amount = math.random(32,48), time = 0.2, glow = 14;
								texture = sorcery.lib.image('sorcery_spark.png'):glow(sorcery.lib.color(10,20,255)):render();
								minpos = vector.subtract(pos, {x=0.5,y=0.5,z=0.5});
								maxpos = vector.add     (pos, {x=0.5,y=0.5,z=0.5});
								minvel = {x = -0.3, y = -0.3, z = -0.3};
								maxvel = {x =  0.3, y =  0.3, z =  0.3};
								minacc = {x = -0.6, y = -0.6, z = -0.6};
								maxacc = {x =  0.6, y =  0.6, z =  0.6};
								minexptime = 0.4, maxexptime = 1.2;
								minsize = 0.3, maxsize = 1.2;
								animation = {
									type = 'vertical_frames', length = 1.3;
									aspect_w = 16, aspect_h = 16;
								};
							}
							minetest.dig_node(pos)
							if math.random(5) == 1 then
								minetest.set_node(pos, {name='sorcery:air_flash_' .. tostring(math.random(10))})
							end
							-- TODO visuals
						end
						local r = s.tunnel_radius
						local yaw = {x=0,y=s.tunnel_angle,z=0}
						s.visual {
							amount = 16, time = 3, glow = 14;
							texture = sorcery.lib.image('sorcery_sparking.png'):glow(sorcery.lib.color(20,60,255)):render();
							minpos = vector.subtract(s.anchor, {x=r,y=r,z=r});
							maxpos = vector.add     (s.anchor, {x=r,y=r,z=r});
							minvel = {x = -0.1, y = -0.1, z = -0.1};
							maxvel = {x =  0.1, y =  0.1, z =  0.1};
							minexptime = 1.0, maxexptime = 1.4;
							minsize = 1.5, maxsize = 4;
							animation = {
								type = 'vertical_frames', length = 1.5;
								aspect_w = 64, aspect_h = 64;
							};
						}
						for x=-r,r do for y=-r,r do
							local xs = x < 0 and -1 or 1
							local ys = y < 0 and -1 or 1
							if x^2 + y^2 <= r^2 then
								if (x+xs)^2 + y^2 > r^2 or
								   (y+ys)^2 + x^2 > r^2 then
								   -- we're right at the edge - make a mess
								   if math.random(5) == 1 then goto skip end
								end
								local p = vector.add(s.anchor,vector.rotate({x=x,y=y,z=0},yaw))
								smash(p)
							end
						::skip::end end
						-- if math.random(1,10) == 1 then
						-- 	s.tunnel_angle = s.tunnel_angle + math.random(-0.05,0.05)
						-- 	yaw.y = s.tunnel_angle
						-- end
						if math.random(1,21) == 1 then
							s.tunnel_radius = math.min(6,math.max(3,s.tunnel_radius + math.random(-1,1)))
						end
						local dir = vector.rotate({x=0,y=0,z=1},yaw)
						if sorcery.lib.math.vdcomp(1, dir) < 1 then
							dir = vector.normalize(dir)
						end
						s.anchor = vector.add(s.anchor,dir)
					end
					local tp = 0
					for i=1,tunnel_depth do
						local now = {whence=0,secs=tp}
						timeline[now] = function(...) cut(i,...) end
						sounds[now] = {
							sound='sorcery_crunch', where='pos';
							ephemeral=true, gain = math.random(3,10) * 0.1;
						}
						tp = tp + (math.random(2,5) * 0.1)
					end
					sounds[1] = {sound='sorcery_powerdown', where='pos'}
					sorcery.spell.cast {
						name = 'sorcery:excavate';
						caster = ctx.caster;
						duration = tp;
						timeline = timeline, sounds = sounds;
						-- spell state
						anchor = ctx.target.under;
						tunnel_angle = ctx.caster:get_look_horizontal();
						tunnel_radius = math.floor(math.random(3,5) * (ctx.stats.power * 0.1));
					}
				end;
			};
			emerald = {
				name = 'Boring';
				desc = 'Release the force of this amulet to punch a deep borehole down into the earth below';
			};
			amethyst = {
				name = 'Shaftcutting';
				desc = 'Cut a wide shaft up into the ceiling of a cavern';
			};
		};
	};
	genesis = {
		name = 'Genesis';
		tone = {235,0,175};
		minpower = 3;
		rarity = 12;
		amulets = {
			amethyst = {
				name = 'Shelter';
				desc = 'Pour the power of this amulet into the soil or sand and out will grow a warm and well-lit place of shelter, no matter how far you may be from civilization.';
				cast = function(ctx)
					local ctr = ctx.caster:get_pos()
					local dim = { rmax = 2 + 6 * (ctx.stats.power*0.1) }
					dim.rx = math.random(math.max(3, dim.rmax - 4), dim.rmax)
					dim.rz = math.random(math.max(3, dim.rmax - 4), dim.rmax)
					dim.y = math.random(4, 7)
					for i = 1,10 do
						if sorcery.lib.node.is_air(ctr) then
							ctr = ctr:offset(0, -1, 0)
						else break end
					end
					local soilp = function(pos)
						local name = minetest.get_node(pos).name
						return minetest.get_item_group(name, 'soil') ~= 0 or
						       minetest.get_item_group(name, 'sand') ~= 0
					end
					if not soilp(ctr) then return false end
					-- scan map to determine maximum dimensions
					for x = -dim.rx, dim.rx do
						for z = -dim.rz, dim.rz do
							local pos = ctr:offset(x,0,z)
							if not soilp(pos) then
								dim.rx = math.min(dim.rx, math.abs(x))
								dim.rz = math.min(dim.rz, math.abs(z))
							end
							for y = 1, dim.y do
								if not sorcery.lib.node.is_air(pos:offset(0,y,0)) then
									dim.y = math.min(dim.y, y)
								end
							end
						end
					end
					-- bail if not enough room
					if dim.rx < 2 or dim.rz < 2 or dim.y < 2 then
						return false
					end
					local materials = {
						walls = {};
						corners = {};
						floor = {};
						roof = {};
						door = {};
						door_lock = {};
						lamp_floor = {};
						lamp_ceil = {};
						lamp_wall = {};
						lamp_ext = {};
					}
					local try = function(ary, ...)
						local a = {}
						for _, item in pairs{...} do
							if item and minetest.registered_items[item] then
								table.insert(a, item)
							end
						end
						if next(a) then
							table.insert(ary, a)
							return true
						end
						return false
					end
					local daytime = minetest.get_natural_light(ctr:offset(0,1,0)) > 9
					for name,tree in pairs(sorcery.data.trees) do
						try(materials.corners, tree.node)
						try(materials.walls, tree.plank)
						try(materials.floor, tree.plank)
					end
					try(materials.roof, 'farming:straw')
					try(materials.floor, 'farming:straw')
					try(materials.roof, 'default:brick')
					try(materials.roof, 'default:desert_stonebrick')
					try(materials.walls, 'default:brick')       try(materials.corners, 'default:brick')
					try(materials.walls, 'default:stonebrick') try(materials.corners, 'default:stonebrick')
					try(materials.walls, 'default:cobble', 'default:mossycobble')
					try(materials.walls, 'default:desert_cobble')
					try(materials.walls, 'default:desert_stonebrick')

					try(materials.corners, 'default:desert_stonebrick')
					try(materials.corners, 'default:desert_stonebrick')
					try(materials.corners, 'default:desert_stone_block')

					try(materials.walls, 'default:sandstone')
					try(materials.walls, 'default:sandstonebrick')
					try(materials.walls, 'default:desert_sandstone')
					try(materials.walls, 'default:desert_sandstone_brick')
					try(materials.walls, 'default:silver_sandstone')
					try(materials.walls, 'default:silver_sandstone_brick')

					try(materials.walls, 'default:sandstone', 'default:silver_sandstone', 'default:desert_sandstone')
					try(materials.walls, 'default:sandstonebrick', 'default:silver_sandstone_brick', 'default:desert_sandstone_brick')
					try(materials.roof, 'default:sandstonebrick', 'default:silver_sandstone_brick', 'default:desert_sandstone_brick')

					try(materials.corners, 'default:sandstonebrick')
					try(materials.corners, 'default:desert_sandstone_brick')
					try(materials.corners, 'default:silver_sandstone_brick')
					if math.random(1,10) == 7 then
					try(materials.corners, 'default:obsidianbrick')
					end

					try(materials.lamp_wall, 'default:torch_wall')
					try(materials.lamp_wall, 'morelights_modern:walllamp')

					try(materials.lamp_ext, 'default:torch_wall')
					try(materials.lamp_ext, 'morelights_modern:walllamp')
					try(materials.lamp_ext, 'morelights_vintage:lantern_w')

					for _, l in pairs {
						'default:meselamp';
						'morelights_modern:barlight_s';
						'morelights_modern:ceilinglight';
						'morelights_modern:canlight_d';
						'morelights_modern:canlight_l';
						'morelights_vintage:hangingbulb';
						'morelights_vintage:chandelier';
					} do try(materials.lamp_ceil, l) end

					for _, l in pairs {
						'default:meselamp';
						'morelights_extras:f_block',
						'morelights_extras:sandstone_block',
						'morelights_extras:stone_block',
						'morelights_modern:block',
						'morelights_vintage:block',
					} do try(materials.lamp_floor, l) end

					for _, d in pairs {
						'doors:door_wood';
						'doors:woodglass_door';
						'doors:slide_door';
						'doors:japanese_door';
						'doors:screen_door';
						'doors:door_glass';
					} do try(materials.door, d) end

					for _, d in pairs {
						'doors:door_steel';
						'xpanes:door_steel_bar';
					} do try(materials.door_lock, d) end


					for k,v in pairs(materials) do
						if next(v) then
							materials[k] = select(2, sorcery.lib.tbl.pick(v))
						end
					end
					local timeline = {}
					local per = 0.05
					local i = 0
					local spark = function(s,where)
						s.visual {
							amount = 30;
							time = 0.2;
							minpos = where:offset(-0.5,  0.0, -0.5);
							maxpos = where:offset( 0.5,  1.0,  0.5);
							minvel = vector.new(-0.4, -0.2, -0.4);
							maxvel = vector.new( 0.4,  0.2,  0.4);
							minacc = vector.new( 0, 0.2, 0);
							maxacc = vector.new( 0, 0.6, 0);
							minexptime = 0.2, maxexptime = 2.0;
							minsize = 0.3, maxsize = 2;
							texture = sorcery.vfx.glowspark(sorcery.lib.color(255,12,89)):render();
							glow = minetest.LIGHT_MAX;
							animation = {
								type = 'vertical_frames', length = 3.1;
								aspect_w = 16, aspect_h = 16;
							}
						}
					end
					local setplane = function(y, mcat)
						for x = -dim.rx, dim.rx do
							for z = -dim.rz, dim.rz do
								timeline[{whence=0, secs=per*i}] = function(s)
									local node = select(2, sorcery.lib.tbl.pick(mcat))
									local p = ctr:offset(x,y,z)
									minetest.set_node(p, {name=node})
									spark(s,p)
								end
							end
							i = i + 1
						end
					end
					setplane(0, materials.floor)
					local mpick=function(t)
						return select(2, sorcery.lib.tbl.pick(materials[t]))
					end
					local t_supports = per*i
					for y = 1, dim.y-1 do
						local mx,mz = dim.rx, dim.rz
						timeline[{whence=0, secs=per*i}] = function(s)
							for _,where in pairs {
								ctr:offset( mx,y, mz);
								ctr:offset(-mx,y, mz);
								ctr:offset(-mx,y,-mz);
								ctr:offset( mx,y,-mz);
							} do
								minetest.set_node(where, { name = mpick'corners' })
							end
						end
						i=i+1
					end
					local t_roof = per*i
					setplane(dim.y, materials.roof)
					timeline[{whence=0,secs=per*i - 0.4}] = function(s)
						s.visual {
							time = 30, amount = 2500;
							minpos = ctr:offset(-dim.rx,dim.y - 0.5,-dim.rz);
							maxpos = ctr:offset( dim.rx,dim.y + 0.5, dim.rz);
							minacc = vector.new(0,-0.5,0);
							maxacc = vector.new(0, 0.3,0);
							texture = sorcery.lib.image('sorcery_sputter.png'):glow(255,17,86):render();
							glow = minetest.LIGHT_MAX;
							minexptime = 2.4, maxexptime = 8;
							minsize = 0.5, maxsize = 3;
							animation = {
								type = 'vertical_frames', length = 1.1;
								aspect_w = 16, aspect_h = 16;
							};
						}
					end
					local t_walls = per*i
					for y = dim.y-1,1,-1 do
						timeline[{whence=0, secs=per*i}] = function(s)
							local xe, ze = dim.rx-1, dim.rz-1
							for x=-xe,xe do
								minetest.set_node(ctr:offset(x,y, dim.rz), {name=mpick'walls'})
								minetest.set_node(ctr:offset(x,y,-dim.rz), {name=mpick'walls'})
							end
							for z=-ze,ze do
								minetest.set_node(ctr:offset(-dim.rx,y,z), {name=mpick'walls'})
								minetest.set_node(ctr:offset( dim.rx,y,z), {name=mpick'walls'})
							end
						end
						i=i+1
					end
					local t_built = per*i
					local lighting = math.random(1,2)
					local put_align = function(where,node,dir)
						local dfn = function() return 0 end
						local pt2 = minetest.registered_nodes[node].paramtype2
						if pt2 == 'facedir' or pt2 == 'wallmounted' then
							dfn = minetest['dir_to_' .. pt2]
						end
						minetest.set_node(where, { name=node, param2=dfn(dir) })
					end
					if lighting == 1 then
						local lh = math.ceil(dim.y * .6)
						local wlamps = {
							ctr:offset(  dim.rx - 1 , lh,0);
							ctr:offset(-(dim.rx - 1), lh,0);
							ctr:offset(0, lh,   dim.rz - 1);
							ctr:offset(0, lh, -(dim.rz - 1));
						}
						sorcery.lib.tbl.shuffle(wlamps)
						for _, where in pairs(wlamps) do
							i = i + 10
							timeline[{whence=0, secs = per*i}] = function(s)
								spark(s,where)
								local node = select(2,sorcery.lib.tbl.pick(materials.lamp_wall))
								minetest.sound_play('sorcery_put',{pos=where,gain=0.8},true)
								put_align(where, node, vector.normalize(ctr:offset(0,lh,0) - where)*-1)
							end
						end i=i+1
					elseif lighting == 2 then
						local which = math.random(1,3)
						if which == 1 or which == 2 then
							i = i + 20
							timeline[{whence=0, secs = per*i}] = function(s)
								local where = ctr:offset(0,dim.y,0)
								spark(s,where)
								minetest.sound_play('sorcery_put',{pos=where,gain=0.7},true)
								minetest.item_place(ItemStack(mpick'lamp_ceil'), nil, {
									type = "node";
									under = where;
									above = where:offset(0,-1,0);
								})
							end
						end
						if which == 1 or which == 3 then
							i = i + 20
							local flamps = {
								ctr:offset( (dim.rx - 1), 0,  (dim.rz - 1));
								ctr:offset(-(dim.rx - 1), 0,  (dim.rz - 1));
								ctr:offset(-(dim.rx - 1), 0, -(dim.rz - 1));
								ctr:offset( (dim.rx - 1), 0, -(dim.rz - 1));
							}
							sorcery.lib.tbl.shuffle(flamps)
							for _, v in pairs(flamps) do
								timeline[{whence=0, secs = per*i}] = function(s)
									local node = select(2,sorcery.lib.tbl.pick(materials.lamp_floor))
									spark(s,v)
									minetest.sound_play('sorcery_put',{pos=v,gain=0.7},true)
									minetest.set_node(v, {name = node})
								end
								i = i + 7
							end
						end
					end

					-- cut out door
					local doorside = ({
						vector.new( dim.rx,1,0);
						vector.new(0,1, dim.rz);
						vector.new(-dim.rx,1,0);
						vector.new(0,1,-dim.rz);
					})[math.random(1,4)]
					local doorslideaxis = doorside.z ~= 0 and 'x' or 'z'
					local doorpos
					if math.random(1,3) == 1 then
						local d = dim['r'..doorslideaxis] - 1
						doorside[doorslideaxis] = doorside[doorslideaxis] + math.random(-d,d)
					end
					doorpos = ctr + doorside
					local door = mpick'door'
					i=i+5
					timeline[{whence=0,secs=per*i}] = function(s)
						minetest.remove_node(doorpos)
						minetest.remove_node(doorpos:offset(0,1,0))
						spark(s, doorpos)
						spark(s, doorpos:offset(0,1,0))
						minetest.sound_play('sorcery_crunch', {pos = doorpos, gain = 0.9}, true)
						-- this is buggy af and needs to be replaced with a proper impl
-- 						local d = ItemStack(door)
-- 						d:get_definition().on_place(d, s.caster, {
-- 							type = 'node';
-- 							above = doorpos;
-- 							under = doorpos:offset(0,-1,0);
-- 						})
					end

					-- install outdoor lighting
					if math.random(1,7) == 1 or not daytime then
						local xwall,xspc = dim.rx, dim.rx+1
						local zwall,zspc = dim.rz, dim.rz+1
						local lh = dim.y - 1
						for _, o in pairs(sorcery.lib.node.offsets.corners) do
							local w = ctr:add {
								z = dim.rz * o.z;
								x = dim.rx * o.x;
								y = lh;
							}
							local put = function(ofs)
								timeline[{whence=0,secs=per*i}] = function(s)
									local p = vector.add(w,ofs)
									if not sorcery.lib.node.is_clear(p) then return end
									spark(s, p)
									minetest.sound_play('sorcery_put', {pos = p, gain = 0.4}, true)
									local lamp = mpick 'lamp_ext'
									put_align(p, lamp, vector.multiply(ofs,-1))
								end
								i=i+1
							end
							put{x=o.x, y=0, z=0}
							put{x=0,   y=0, z=o.z}
						end
					end

					-- lay down bed
					i=i+3
					timeline[{whence=0,secs=per*i}] = function(s)
						local bed = 'beds:bed'
						if math.random(1,3) == 1 then bed = 'beds:fancy_bed' end
						local top = ctr:offset(dim.rx-1,1,dim.rz-1)
						sorcery.lib.node.install_bed(bed, top, vector.new(1,0,0))
						spark(s, top)
						spark(s, top:offset(-1,0,0))
						minetest.sound_play('sorcery_put', {pos = doorpos, gain = 0.9}, true)
					end

					sorcery.spell.cast {
						name = 'sorcery:shelter';
						groups = {'genesis','construct'};
						caster = ctx.caster;
						anchor = ctr;
						radius = math.max(dim.rz, dim.rx);
						duration = per * i;
						timeline = timeline;
						sounds = {
							[{whence=0,secs=t_supports}] = {
								sound = 'sorcery_slide';
								where = ctr:offset(0,dim.y,0);
								ephemeral = true, pitch = 0.7;
							};
							[{whence=0,secs=t_roof}] = {
								sound = 'sorcery_slide';
								where = ctr:offset(0,dim.y,0);
								ephemeral = true;
							};
						};
					}
				end;
			};
			mese = {
				mingrade = 4;
				name = 'Duplication';
				desc = 'Bring an exact twin of any object or item into existence, whether it be a thing quotidian or an impossible rarity';
				cast = function(ctx)
					local color = sorcery.lib.color(255,61,205)
					local dup, sndpos, anchor, sbj, ty
					if ctx.target.type == 'object' and ctx.target.ref:get_luaentity().name == '__builtin:item' then
						sorcery.vfx.imbue(color, ctx.target.ref) -- causes graphics card problems???
						sndpos = 'subjects'
						sbj = {{player = ctx.target.ref}}
						local item = ItemStack(ctx.target.ref:get_luaentity().itemstring)
						local r = function() return math.random() * 2 - 1 end
						local putpos = vector.offset(ctx.target.ref:get_pos(), r(), 1, r())
						dup = function()
							item:set_count(1) -- nice try bouge-san
							return minetest.add_item(putpos, item), false
						end
					elseif ctx.target.type == 'node' then
						ty = minetest.get_node(ctx.target.under).name
						sorcery.vfx.imbue(color, ctx.target.under)
						sndpos = 'pos';
						anchor = ctx.target.under;
						dup = function()
							local origmeta = minetest.get_meta(ctx.target.under):to_table()
							origmeta.inventory = nil
							local npos
							do local vp = {}
								for _, of in pairs(sorcery.lib.node.offsets.neighbors) do
									local sum = vector.add(ctx.target.under, of)
									if sorcery.lib.node.is_clear(sum) then
										vp[#vp+1] = sum
									end
								end
								if #vp > 0 then npos=vp[math.random(#vp)] end
							end
							if npos then
								minetest.place_node(npos, minetest.get_node(ctx.target.under))
								-- if minetest.registered_nodes[ty].on_construct then
								-- 	minetest.registered_nodes[ty].on_construct(npos)
								-- end
								minetest.get_meta(npos):from_table(origmeta)
								return npos, true
							else
								local nstack = ItemStack(ty)
								nstack:get_meta():from_table(origmeta)
								local leftover = ctx.caster:get_inventory():add_item('main',nstack)
								if leftover and not leftover:is_empty() then
									minetest.add_item(ctx.caster:get_pos(), leftover)
								end
							end
						end
					else
						return false
					end
					if minetest.get_item_group(ty,'do_not_duplicate') ~= 0 then
						return true
					end

					sorcery.spell.cast {
						name = 'sorcery:duplicate';
						groups = {'genesis'};
						caster = ctx.caster;
						duration = math.random(10,20) * ((10 - ctx.stats.power)*0.1);
						anchor = anchor;
						timeline = {
							[{whence=0, secs=1}] = function(s,te,tl)
								local mag = sbj and 0.5 or 0.7
								local pv = sbj and vector.new(0,0,0) or ctx.target.under
								local vfn = (sbj and s.visual_subjects or s.visual)
								vfn {
									amount = tl * 30, time = tl;
									minpos = vector.offset(pv,-mag,-mag,-mag);
									maxpos = vector.offset(pv, mag, mag, mag);
									minsize = 0.5, maxsize = 2.3;
									minexptime = 1.0, maxexptime = 1.5;
									texture = sorcery.lib.image('sorcery_sputter.png'):glow(color):render();
									animation = {
										type = 'vertical_frames', length = 1.6;
										aspect_w = 16, aspect_h = 16;
									};
								}
							end;
							[1] = function(s,te)
								local where, node = dup()
								if where == nil then return end
								local pv = node and where or vector.new(0,0,0)
								local mp = (not node) and vector.new(0,0,0) or {
									x = 0.5, y = 0.5, z = 0.5
								}
								minetest.add_particlespawner {
									amount = 170, time = 0.2;
									minpos = vector.subtract(pv,mp);
									maxpos = vector.add(pv,mp);
									attached = (not node) and where or nil;
									minvel = {x = -2.0, y = -1.8, z = -2.0};
									maxvel = {x =  2.0, y =  0.2, z =  2.0};
									minacc = {x = -0.0, y = -0.1, z = -0.0};
									maxacc = {x =  0.0, y = -0.3, z =  0.0};
									minsize = 0.3, maxsize = 2;
									minexptime = 1, maxexptime = 3.0;
									texture = sorcery.lib.image('sorcery_spark.png'):glow(color):render();
									animation = {
										type = 'vertical_frames', length = 3.1;
										aspect_w = 16, aspect_h = 16;
									};
								}
							end;
						};
						sounds = {
							[0] = {
								sound = 'sorcery_duplicate_bg';
								where = sndpos, stop = 1, fade = 2;
							};
							[1] = {
								sound = 'sorcery_genesis';
								where = sndpos, ephemeral = true;
							};
						};
					}
				end;
			};
		};
	};
	luminate = {
		name = 'Luminate';
		tone = {255,194,0};
		minpower = 1;
		rarity = 8;
		amulets = {
			luxite = {
				name = 'Glow';
				desc = 'Swathe yourself in an aura of sparkling radiance, casting light upon all the dark places where you voyage';
				cast = function(ctx)
					local fac = (ctx.stats.power * 0.1)
					local radius = 2 + 5*fac
					local period = 0.4 - 0.3*fac
					local glowduration = 5 + 50*fac
					sorcery.spell.cast {
						name = "sorcery:glow";
						caster = ctx.caster;
						subjects = {{player=ctx.caster}};
						duration = 40 + 120*fac;
						nodes = {};
						disjoin = function(self)
							for _,n in pairs(self.nodes) do
								if sorcery.lib.str.beginswith(minetest.get_node(n).name,'sorcery:air_glimmer_') then
									minetest.remove_node(n)
								end
							end
						end;
						intervals = {
							{period = period, after = {whence=0,secs=0.7}, fn = function(c)
								for _,sub in pairs(c.spell.subjects) do
									local ox, oy, oz = math.random(-radius,radius),
													   math.random(-radius,radius),
													   math.random(-radius,radius)
									local pos = vector.offset(sub.player:get_pos(), ox,oy,oz)
									-- print('pos',minetest.pos_to_string(pos),'player',minetest.pos_to_string(sub.player:get_pos()))
									if sorcery.lib.node.is_air(pos) then
										local power = math.random(4,minetest.LIGHT_MAX)
										minetest.set_node(pos, {
											name = 'sorcery:air_glimmer_' .. tostring(power);
										})
										c.spell.nodes[#c.spell.nodes + 1] = pos
										local d = glowduration * (0.5 + math.random()*0.5)
										local m = minetest.get_meta(pos)
										m:set_float('duration', d)
										m:set_float('timeleft', d)
										m:set_int('power', power)
									-- else
									-- 	print('not air!', dump(minetest.get_node(pos)))
									end
								end
							end};
						};
					}
				end;
				iridium = {
					name = 'Aura';
					desc = 'Dazzling golden luminance emanates from the bodies of all those around you, and you walk in light even amid the darkest depths of the earth';
				};
			};
			diamond = {
				name = 'Radiance';
				desc = 'Set the air around you alight with a mystic luminance, letting you see clearly a great distance in every direction for several minutes';
				frame = {
					iridium = {
						name = 'Sunshine';
						mingrade = 5;
						desc = 'Unleash the power of this amulet to seize ultimate control over the forces of nature and summon the Sun high into the nighttime sky';
						cast = function(ctx)
							local time = minetest.get_timeofday()
							if not (time < 0.3 or time > 0.7) then return false end
							local diff = 0.5 - time
							local frames = 40
							local duration = 1.5
							local delta = diff / frames
							local tl = {}
							for i=1,frames do
								local wh = {whence=0, secs=duration*(i/frames)}
								tl[wh] = function(s)
									minetest.set_timeofday(time + delta*i)
								end
							end
							sorcery.spell.cast {
								name = 'sorcery:sunshine';
								caster = ctx.caster;
								anchor = 'caster';
								timeline = tl;
								duration = duration;
							}
						end;
					};
				};
			};
		};
	};
	dominate = {
		name = 'Dominate';
		tone = {235,0,228};
		minpower = 3;
		rarity = 12;
		amulets = {
			amethyst = {
				name = 'Suffocation';
				desc = 'Wrap this spell tightly around your victim\'s throat, cutting off their oxygen until you release them.';
			};
			emerald = {
				name = 'Caging';
				desc = 'Trap your victim in an impenetrable field of force, leaving them with no way out but translocation or waiting for the field to release them';
			};
			luxite = {
				name = 'Vampiric Aura';
				desc = 'For a time, anyone who approaches you, whether friend or foe, will suffer immediate retaliation as they are quickly sapped of their vital force in order to replenish your own';
			};
			ruby = {
				name = 'Exsanguination';
				desc = 'Rip the life force out of another, leaving them on the brink of death, and use it to mend your own wounds and invigorate your being';
				cast = function(ctx)
					if not (ctx.target and ctx.target.type == 'object') then return false end
					local tgt = ctx.target.ref
					local takefac = math.min(99,50 + (ctx.stats.power * 5)) / 100
					local dmg = tgt:get_hp() * takefac

					local numhits = math.random(6,10+ctx.stats.power/2)
					local function dohit(hitsleft)
						if tgt == nil or tgt:get_properties() == nil then return end
						tgt:punch(ctx.caster, 1, {
							full_punch_interval = 1;
							damage_groups = { fleshy = dmg / numhits }
						})
						local tgth = tgt:get_properties().eye_height
						sorcery.vfx.bloodburst(vector.add(tgt:get_pos(),{x=0,y=tgth/2,z=0}),math.random(10 * takefac, 40 * takefac))
						ctx.caster:set_hp(ctx.caster:get_hp() + math.max(1,(dmg/numhits)*takefac))

						local sound = {'sorcery_bloody_hit','sorcery_crunch',false}
						sound = sound[math.random(#sound)]
						if sound ~= false then
							minetest.sound_play(sound, { pos = pos, gain = math.random(5,15)*0.1 })
						end

						local nexthit = math.random() * 0.4 + 0.1
						local dir = vector.subtract(ctx.caster:get_pos(), tgt:get_pos())
						local spark = sorcery.lib.image('sorcery_spark.png')
						minetest.add_particlespawner {
							amount = math.random(80*takefac,150*takefac);
							texture = spark:blit(spark:multiply(sorcery.lib.color(255,20,10))):render();
							time = nexthit;
							attached = tgt;
							minpos = {x = -0.3, y = -0.5, z = -0.3};
							maxpos = {x =  0.3, y = tgth, z = 0.3};
							minvel = vector.multiply(dir,0.5);
							maxvel = vector.multiply(dir,0.9);
							minacc = vector.multiply(dir,0.1);
							maxacc = vector.multiply(dir,0.2);
							minexptime = nexthit * 1.5;
							maxexptime = nexthit * 2;
							minsize = 0.5;
							maxsize = 5 * takefac;
							glow = 14;
							animation = {
								type = 'vertical_frames';
								aspect_w = 16, aspect_h = 16;
								length = nexthit*2 + 0.1;
							};
						}

						if hitsleft > 0 then
							minetest.after(nexthit, function() dohit(hitsleft-1) end)
						end
					end
					dohit(numhits)
				end;
			};
			amethyst = {
				name = 'Disarming';
				desc = 'Wield this amulet against a foe to rip all the weapons in their possession out of their grasp';
				frame = {
					iridium = {
						name = 'Peacemaking';
						desc = 'Confiscate all weapons held by those surrounding you';
					};
				};
			};
		};
	};
}