packages feed

scroll-1.20250228: Spell.hs

module Spell where

import Control.Monad.State.Strict
import Control.Monad (when)
import Control.Monad.Compat ((<=<))
import Data.Foldable (forM_)
import qualified Data.Set as S
import qualified Data.Map as M
import qualified Data.CaseInsensitive as CI
import qualified Data.Vector.Mutable as MV
import Data.Char
import Data.Maybe
import Control.Monad.IfElse
import Control.Applicative
import Prelude

import Spell.Enum
import CharMap
import Types
import Player
import Time
import World
import Status
import Unicode
import Level.Border
import Rand
import DeepCopy
import Invert
import Term

type SpellAction = M NextStep -> M NextStep

-- All spells in the game. Note though that the spells active in the world
-- can change at run time; it's in the state.
allSpells :: S.Set Spell
allSpells = S.fromList
	[ spell SpellWhiteout "Whiteout" (++ " will clear the way ahead") whiteOut
	, spell SpellGenocide "Genocide Letter" (\i -> "Use " ++ i ++ " with care, it wipes the scroll bare!" ) genocide
	, spell SpellWish "Wish" (++ " will give you any letter you wish" ) wish
	] `S.union` startingSpells `S.union` maybeStartingSpells

-- Spells that the player starts off knowing. Both needed to get
-- through the tutorial.
startingSpells :: S.Set Spell
startingSpells = S.fromList
	[ spell SpellVomit "Vomit" (++ " helps when you've swallowed too many letters") vomit
	, spell SpellNew "New" (++ "is a way to change what magic letters do") new
	]

-- Spells that the player may start off knowing. (Less powerful.)
maybeStartingSpells :: S.Set Spell
maybeStartingSpells = S.fromList
	[ spell SpellDream "Dream" (++ " lulls you away into dreamland" ) dream  
	, spell SpellReverse "Reverse" (++ "turns back to front") reversePlayer
	, spell SpellBerzerk "Berzerk" (++ " makes you mad and strong") berzerk
	]

invokeString :: String -> String
invokeString ingredients = "\"" ++ map toUpper ingredients ++ "\""

spell :: SpellEnum -> SpellName -> (String -> String) -> (M NextStep -> M NextStep) -> Spell
spell spellenum name hint = Spell name 
	(hint (invokeString ingredients))
	(S.fromList $ map CI.mk ingredients)
	ingredients
  where
	ingredients = findingredients 0 ""
	findingredients n s = case M.lookup (IngredientFor spellenum n) charUseMap of
		Nothing
			| null s -> error $ "Internal error: No ingredients defined for " ++ show spellenum
			| otherwise -> reverse s
		Just i -> findingredients (succ n) (i:s)

vomit :: SpellAction
vomit cont = do
	showMessage "You cast a spell to clear your stomach."
	p <- gets player
	let l = catMaybes $ playerSwallowing p : (map segmentSwallowed (playerBody p))
	removeIngredients' (S.fromList $ map CI.mk l)
	modifyPlayer $ removeSwallowing (const True)
	writeWorld (getPos p) '*'

	when (playerLen p > 4 && length (playerBody p) > 4 && length l >= 3)  $ do
		roll <- randM $ randomR (0, 15)
		when (playerLen p > roll) $ do
			modifyPlayer $ \pl -> pl
				{ playerLen = playerLen pl - 1
				, playerBody = reverse (drop 1 (reverse (playerBody pl)))
				}
			showMessage "You feel queasy after vomiting up those letters.. You lose a tail segment."
	cont

new :: SpellAction
new = promptingredient
  where
	promptingredient cont = do
		swallowed <- mapMaybe segmentSwallowed . playerBody <$> gets player
		if null swallowed 
			then noingredients cont
			else prompt ("Pick the new spell's ingredient: [" ++ swallowed ++ "]") $ \i ->
				case inputCharacter i of
					Just c | (CI.mk c `elem` map CI.mk swallowed) -> do
						removeIngredients' (S.singleton (CI.mk c))
						possibilities <- checkSpells (const True) <$> gets player
						case possibilities of
							[] -> noingredients cont
							[s] -> do
								addnewspell c s
								cont
							_ -> promptknownspell cont c
					_ -> promptingredient cont
	
	noingredients cont = do
		showMessage "Failed to create new spell: You have no spell ingredients swallowed."
		cont

	promptknownspell cont ingredient = do
		let retry = promptknownspell cont ingredient
		prompt ("What spell should " ++ invokeString [ingredient] ++ " invoke?") $ \i ->
			case inputCharacter i of
				Just c -> do
					p <- gets player
					case toggleInvoke c p of
						(InvokedChar, p') -> do
							change p'
							case checkInvokedSpells p' of
								[] -> retry
								l -> do
									mapM_ (addnewspell ingredient) l
									cont
						(DeInvokedChar, p') -> do
							change p'
							retry
						(NoInvoke, _) -> retry
				Nothing -> retry
	change p' = modifyPlayer (const p')

	addnewspell ingredient basespell = do
		let i = S.singleton (CI.mk ingredient)
		let newspell = basespell { spellIngredients = i, spellWord = [ingredient] }
		removeIngredients basespell
		p <- gets player
		change $ p { playerSpells = S.insert newspell (playerSpells p) }
		showMessage $ "You can now cast " ++ spellName newspell ++ " using " ++ invokeString [ingredient]

whiteOut :: SpellAction
whiteOut cont = do
	p <- gets player
	writeWorld (playerHead p) ' '
	modifyPlayer $ removeSwallowing (const True)
	case playerBody p of
		(s:_) | segmentDirection s /= DDive ->
			showeffect (\n -> "You cast Whiteout forward " ++ show n ++ " spaces") 
				=<< beam 
					(directionOffset $ segmentDirection s)
					(playerHead p)
					(beamlength p)
					(0 :: Integer)
		_ -> showeffect (const "You cast Whiteout all around your head.")
			=<< around (playerHead p)
	cont
  where
	showeffect _ 0 = showMessage "The Whiteout has no effect on the scroll's border."
	showeffect msg n = showMessage (msg n)

	beamlength :: Player -> Integer
	beamlength p = ceiling (fromIntegral (playerLen p) / if playerEnergized p then 1 else 2 :: Double)

	beam _ _ 0 n = return n
	beam offset pos len n = do
		let pos' = offsetPos offset pos
		ok <- effect pos'
		if ok
			then beam offset pos' (len - 1) (n + 1)
			else return n

	around pos = length . filter id
		<$> mapM (effect . (`offsetPos` pos)) (zip offs offs)
	offs = [succ, pred, id]

	effect pos@(_x,y) = do
		maxy <- worldHeight
		if y >= maxy
			then return False
			else do
				c <- readWorld pos
				if isBoundry c || y >= maxy
					then return False
					else do
						writeWorld pos ' '
						return True

genocide :: SpellAction
genocide = promptletter
  where
	promptletter cont = prompt "Genocide which letter?" $ \i -> case inputCharacter i of
		Just c
			| isBoundry c || isSpace c -> do
				showMessage "The spell fizzles and dies. Nice try."
				cont
			| otherwise -> removeall c cont
		Nothing -> promptletter cont

	removeall c cont = do
		let toremove = CI.mk c
		let removable = (== toremove)
		p <- gets player
		let playercharacters = '@' : map (bodyChar . segmentDirection) (playerBody p)
		let selfgenocide = c `elem` playercharacters
		energized <- playerEnergized <$> gets player
		showMessage $ concat
			[ "You genocided all "
			, if energized then "" else "visible "
			, if selfgenocide then [c] else unicodeCharString c ++ "'s!"
			]
		-- remove from mouth
		modifyPlayer $ removeSwallowing (removable . CI.mk)
		-- remove swallowed
		removeIngredients' (S.singleton toremove)
		-- remove from world
		removeworld c
		when energized $
			removebuffer c
		if selfgenocide
			then do
				showMessage "Sadly, that includes parts of you! You die.."
				endThread
			else cont

	removeworld c = mapWorld $ \_ v -> return $
		if (v == c)
			then Just ' '
			else Nothing

	removebuffer c = do
		b <- fst <$> gets bottomBuffer
		let height = MV.length b
		width <- worldWidth
		let onb = \a -> lift (a b)
		forM_ [0..height-1] $ \y ->
			forM_ [0..width-1] $ \x -> do
				v <- readS onb (x,y)
				when (v == c) $
					writeS onb (x,y) ' '

dream :: SpellAction
dream cont = do
	-- Deep copy is needed because S contains mutable vectors,
	-- and the dream would otherwise alter them.
	st <- deepCopy =<< get
	showMessage "You drift into a lucid dream..."
	whenM (randM random) $
		modify $ \s -> s { peruser = nightmarespeed (peruser s) }
	runDream cont cont (wakeupstate st)
  where
  	-- In a nightmare, the scroll will seem to scroll faster.
	nightmarespeed p@(Peruser { peruseSpeed = s } )
		| s > 2 = p { peruseSpeed = s - 1 }
		| otherwise = p
	wakeupstate origst dreamst = origst
		-- Propigate random source to avoid identical
		-- game play; this was not a precognitive dream. ;)
		{ randSource = randSource dreamst
		-- Let the player learn new spells in the dream,
		-- and use them upon awakening.
		, spells = spells dreamst
		-- If the player somehow didn't use help until in the
		-- dream, propigate that state change too.
		, helpShown = helpShown dreamst
		}

runDream :: M NextStep -> M NextStep -> (S -> S) -> M NextStep
runDream sleepcont wakecont wakeupstate = go =<< sleepcont
  where
	go (NextStep v ms) = return $ NextStep v $ Just $ maybe wake (go <=<) ms

	wake _evt = do
		modify wakeupstate
		showMessage $ "You wake from your dream, back where you were!"
		wakecont

berzerk :: SpellAction
berzerk cont = do
	showMessage "You fall into a mighty rage!"
	modifyPlayer $ \p -> p { playerBerzerk = True }
	p <- gets player
	let len = playerLen p
	duration <- randM $ randomR (len, len * 2)
	let minduration = if playerEnergized p then 20 else 10
	delayAction (min minduration duration) cont $
		-- Multiple berzerk spells can be in effect;
		-- the first to expire expires them all.
		whenM (playerBerzerk <$> gets player) $ do
			showMessage "You stop seeing red.."
			modifyPlayer $ \pl -> pl
				{ playerBerzerk = False
				, playerEnergized = False
				}

reversePlayer :: SpellAction
reversePlayer cont = do
	p <- gets player
	if null (playerBody p)
		then fizzle
		else if all (\s -> segmentSide s == CurrentSide) (playerBody p)
			then do
				modifyPlayer $ \_ -> invert p
				success
			else do
				s <- get
				let s' = flipOver s
				put $ s' { player = invert (player s') }
				success
  where
	fizzle = do
		showMessage "The spell seems to do nothing much."
		cont
	success = do
		showMessage "All of a sudden, you're going backwards!"
		cont

wish :: SpellAction
wish cont = prompt ("What letter do you wish for?") $ \ev -> case inputCharacter ev of
	Just want -> do
		result <- if isSpace want
			then do
				roll <-randM $ randomR (1,10 :: Int)
				if roll < 3
					then return '|'
					else return $ toUpper want
			else return $ toUpper want
		showMessage $ "A scattering of " ++ unicodeCharString result ++ "'s appear!"
		width <- worldWidth
		bodypos <- S.fromList <$> wormPositions
		mapWorld $ \p@(x,_) c ->
			if isSpace c || isBoundry c || p `S.member` bodypos
				then return Nothing
				else do
					roll <- randM $ randomR (1,width)
					return $ if roll == x
						then Just result
						else Nothing
		cont
	Nothing -> wish cont

data ToggleInvokeResult
	= InvokedChar
	| DeInvokedChar
	| NoInvoke

toggleInvoke :: Char -> Player -> (ToggleInvokeResult, Player)
toggleInvoke c p = case break matching (playerBody p) of
	(_, []) -> (NoInvoke, p)
	(presegs, s : postsegs) ->
		let invoked = not (segmentInvoked s)
		    s' = s { segmentInvoked = invoked }
		    p' = p { playerBody = presegs ++ [s'] ++ postsegs }
		    result = if invoked then InvokedChar else DeInvokedChar
		in (result, p')
  where
	ci = CI.mk c
	matching s = case segmentSwallowed s of
		Just sc | CI.mk sc == ci -> True
		_ -> False

-- It's possible for multiple spells to use the same ingredients. In this
-- case, all matching spells are returned.
checkInvokedSpells :: Player -> [Spell]
checkInvokedSpells = checkSpells segmentInvoked

checkSpells :: (Segment -> Bool) -> Player -> [Spell]
checkSpells f p = S.toList matches
  where
	ingredients = S.fromList $ map CI.mk $ catMaybes $
		map avail $ playerBody p
	avail s
		| f s = segmentSwallowed s
		| otherwise = Nothing
	match sp = all (`S.member` ingredients) $ S.toList $ spellIngredients sp
	matches = S.filter match (playerSpells p)

removeIngredients :: Spell -> M ()
removeIngredients = removeIngredients' . spellIngredients

-- Remove spell ingredients from the map and from being swallowed.
removeIngredients' :: S.Set (CI.CI Char) -> M ()
removeIngredients' is = do
	segs <- mapM go . playerBody =<< gets player
	modifyPlayer $ \p -> p { playerBody = segs }
  where
	go seg = case segmentSwallowed seg of
		Just c | S.member (CI.mk c) is -> do
			case segmentSide seg of
				CurrentSide -> writeWorld (getPos seg) ' '
				FlipSide -> writeFlipSide (getPos seg) ' '
				InSide -> return ()
			return $ seg
				{ segmentSwallowed = Nothing
				, segmentInvoked = False
				}
		_ -> return seg

-- Invokes all the listed spells, in order.
invoke :: [Spell] -> M NextStep -> M NextStep
invoke [] cont = cont
invoke (s:rest) cont = do
	isSpellCaster
	removeIngredients s
	spellAction s (invoke rest cont)

teachRandomSpell :: Player -> M Player
teachRandomSpell p = do
	let known = playerSpells p
	avail <- gets spells
	let unknown = S.difference avail known
	if S.null unknown
		then do
			showMessage "You wrack your brain, but any more spells, you'll have to learn."
			return p
		else go unknown
  where
	go set = do
		s <- randFrom (S.toList set)
		showMessage $ "You remember a spell: " ++ spellHint s
		return $ p { playerSpells = S.insert s (playerSpells p) }

spellInventory :: M [String]
spellInventory = header . map fmt . S.toList . playerSpells <$> gets player
  where
	header l = "  Spell Inventory" : "" : l
	fmt s = concat 
		[ "  "
		, pad maxspellname $ spellName s
		, pad 6 $ invokeString $ spellWord s
		]
	pad n s = s ++ (replicate (1 + n - length s) ' ')
	maxspellname = maximum $ map (length . spellName) (S.toList allSpells)