Compare commits

..

202 Commits
main ... main

Author SHA1 Message Date
b8011420e6 Allow inspecting things for sale in shops. 2024-06-22 22:37:16 +10:00
851e3d8d1e Document all shops and registered user commands
Include tests to keep it that way.
2024-06-20 22:58:08 +10:00
5c2462a396 Enable first chemistry crafting 2024-06-19 22:28:09 +10:00
fd29618643 Make Ronald a bit weaker at blade to make him a bit easier. 2024-06-13 22:14:47 +10:00
1d890b51ec Make some armour soak damage a bit less punishing 2024-06-12 23:13:14 +10:00
4abbd64cef Fix grammatical error with clothing damage 2024-06-12 23:08:01 +10:00
6bb3e6a335 Implement NPC AI for Ronald
Also add more armour to protect against guns.
2024-06-12 22:40:09 +10:00
78abdb761b Implement the first guns. 2024-06-08 00:20:45 +10:00
8aff296e03 Add the start of Ronald's House
And minor bugfixes on stability of item indexes, making stings less
aggressive.
2024-05-22 22:57:36 +10:00
e238abd832 Create venomous NPCs in Northrad (snakes & scorpions) 2024-05-15 22:58:30 +10:00
90cb258455 Make the desert a dry thirsty place + oasis has water 2024-04-26 23:35:25 +10:00
eec4b933ef Fix Northern Radfields map 2024-04-26 14:45:59 +10:00
ef395b3711 Add the desert part of the radfields 2024-04-25 23:41:11 +10:00
5d008bb52a Add rad detox + assorted fixups
Now ChangeTargetHealth effect is generalised to ChangeTargetParameter
that can also change raddamage (and in the future could cover other
things).
Also fixes a long-standing lmap bug - only show exits where both ends
are in the zone of the current room, rather than just requiring one of
the rooms to be in the zone.
2024-04-07 00:37:39 +11:00
36086b809c Add Geiger Counter and Radsuit 2024-04-04 21:35:29 +11:00
f37baf187e Implement radiation damage 2024-03-22 22:20:26 +11:00
5a270b50dc Missing punctuation. 2024-03-17 16:29:19 +11:00
4752f7a41d Fix up message 2024-03-17 16:27:21 +11:00
8769c8c6eb Allow completing a test to get a rad permit. 2024-03-17 16:25:04 +11:00
f6cda9a0b8 Add OORANS training room 2024-03-12 22:59:10 +11:00
651f1b99c5 Fix build of tests 2024-03-11 21:15:52 +11:00
8a9713b5e1 Don't idlepark from certain special public locations 2024-03-11 21:09:17 +11:00
f7282b07d3 Add some extra exceptions to being idle parked. 2024-03-11 20:59:56 +11:00
e144771018 Rust version upgrade 2024-03-11 20:42:52 +11:00
be0204f5d9 Fix broken test 2024-03-05 22:45:43 +11:00
ff1cb6157d Remove debug code 2024-03-05 22:43:07 +11:00
5c230f2667 Ship idle users to the homeless shelter after a couple of hours 2024-03-05 22:37:33 +11:00
19cef2d9c4 Allow selling in stores, with Josephine special behaviour
Also added a staff invincible mode to help clean out NPCs with wrong
inventory.
2024-02-26 22:35:55 +11:00
a2652e471d Create subsewer rooms 2024-02-24 01:38:39 +11:00
b1fecab1b0 Infest the sewers with crocodiles + minor NPC fixups 2024-02-11 22:21:51 +11:00
18f1bcf241 Add stinkfiends. Fix bug with get. 2024-02-08 22:35:06 +11:00
067d9383ac Do more cleanup of recloned NPCs, but wait 10 minutes instead of 2. 2024-01-29 22:12:16 +11:00
f0aa3d1d08 Minor bugfix: doors shouldn't swing shut if they are already closed. 2024-01-29 21:54:42 +11:00
d1a5a4d2fa Add a required quest to gain access to the sewers 2024-01-29 00:48:41 +11:00
72c48b44c5 Make the sewers dangerous 2024-01-27 18:17:56 +11:00
169e56dc84 Flesh out the full map of Melbs sewers 2024-01-21 22:37:16 +11:00
033cbf2273 Allow plugging in to charge in powered locations 2024-01-17 22:48:42 +11:00
15b96c1b50 Allow buying a lantern that lights up the sewer.
It will be rechargeable but that's not fully implemented yet.
2024-01-15 22:40:45 +11:00
6126bc984b Fix build, prep for Turn Toggle 2024-01-13 18:01:19 +11:00
28654a5130 Beginning of light mechanic implementation 2024-01-12 22:23:02 +11:00
760598e3a1 Document sharing + upgrades 2024-01-07 16:43:26 +11:00
bf3860dd11 Recalculate total stats based off sharing, and inform user of impact 2024-01-06 23:15:41 +11:00
fbfc33857d Add a slightly better item decoding error 2024-01-06 00:07:01 +11:00
dbaf477f49 Add buff reward + fix resolver bug 2024-01-05 23:46:02 +11:00
657ec807e0 Remove less_explicit_mode (no longer sent from client) 2023-12-30 22:25:01 +11:00
68fc094c15 Cleanup most of the less_explicit mode support 2023-12-30 19:07:16 +11:00
58c815f8b4 Remove explicit parameter from display_for_sentence 2023-12-30 16:13:07 +11:00
3c302136d5 Remove most references to less_explicit_mode
Instead add one basic illegality filter for everyone.
2023-12-29 23:48:31 +11:00
a68955deab Remove separate explicit messages from all room broadcasts 2023-12-29 22:45:32 +11:00
b666165ecc Clean up stray file. 2023-12-29 21:21:50 +11:00
cf7e589bc4 Update for new classification. 2023-12-27 11:56:35 +11:00
0c280711e8 Add a mini-game around sharing knowledge
It still needs to apply buffs at the end and a few other details!
2023-12-27 00:34:47 +11:00
706825be20 Add stop command that reverses current action. 2023-10-23 22:12:59 +11:00
76b2874077 Allow feinting during battle 2023-10-20 23:24:20 +11:00
aa4828469a Allow for occassional 'power' attacks
They do more damage but take longer.
2023-10-18 22:25:08 +11:00
6ac3f676be Give some weapons a chance at causing crit status effects. 2023-10-16 22:18:03 +11:00
2350e22f5f Make water a bit less thirst quenching - it's gone a bit too far 2023-10-15 17:12:30 +11:00
fd9d98db79 Add help for more commands. 2023-10-15 17:11:49 +11:00
83a18cfd63 Improve effect system, including messages when effects wear off. 2023-10-14 22:23:06 +11:00
b06f045d10 Move help to separate YAML file. 2023-10-11 22:51:00 +11:00
e53949abf7 NPC data to separate YAML 2023-10-09 21:38:12 +11:00
16341e2c3e Fix warnings from latest version of Rust/Cargo. 2023-10-08 16:47:48 +11:00
807a9612fd Split most room data to YAML for faster compile times. 2023-10-08 16:34:55 +11:00
925deba57e Implement scavenge command 2023-10-06 22:32:15 +11:00
64b96f48ab Restrict access to corp HQ to members + those with consent to fight 2023-10-01 22:45:42 +11:00
f1a23ac811 Fix lmap display for se/nw links 2023-10-01 19:18:49 +11:00
472bdb4f0e Fix bug that leads to some players never having urges like hunger 2023-10-01 17:03:11 +11:00
50f23e1c56 Implement corporate HQs 2023-10-01 16:34:25 +11:00
546f19d3cb More journals, including concept of ones for visiting a room 2023-09-27 23:22:37 +10:00
90a6b3f31b Add a hospital that can heal players who can't heal themselves 2023-09-26 22:33:41 +10:00
92d7b22921 Add wristpad hack concept - enhancements to characters.
And add one hidden in the computer museum.
2023-09-23 23:55:29 +10:00
6dc4a870fc Implement computer museum puzzle. 2023-09-20 23:56:28 +10:00
4467707d4a Start work on a new computer museum zone. The puzzle and reward are
still TODO.
2023-09-17 22:13:19 +10:00
752af74337 Implement a 'poverty discount' to help players stuck with no money. 2023-09-16 22:41:38 +10:00
8461f36fa6 Implement a diner where food can be bought. 2023-09-15 22:39:49 +10:00
704b760c52 Make water more thirst-quenching. 2023-09-15 21:32:04 +10:00
5a5408479a Make stats an alias for status. 2023-09-15 21:31:41 +10:00
96424f3a1f Fix test build. 2023-09-14 23:05:34 +10:00
2beaf0d2af Slow down when urges are high.
Also fix a few issues with concurrency errors from the DB.
2023-09-14 22:52:24 +10:00
daa847b448 Fix bug where we don't stop attacking NPCs when they escape. 2023-09-11 22:29:34 +10:00
d5641a97f7 Fix invalid other_damage_types distributions. 2023-09-11 22:29:11 +10:00
e42bb6b4a9 Implement blades (partially), + fix some attack bugs. 2023-09-11 21:40:41 +10:00
c054c8473a Make craft and fighting cause stress, and too much block them 2023-09-08 22:03:42 +10:00
8336c5be9b Stand up during certain commands 2023-09-07 22:11:21 +10:00
94fc4656f8 Implement stand and recline commands. 2023-09-05 20:55:25 +10:00
3e4e448404 Implement sit command. 2023-09-02 23:48:43 +10:00
57537d80bf Remove misplaced empty file 2023-08-26 09:36:29 +10:00
3023b5317a Add a fill command to fill bottles etc... 2023-08-12 01:36:46 +10:00
fab18d604e Implement hunger, eating, and drinking. 2023-08-05 01:51:42 +10:00
a1495e6731 Implement "report abuse" command. 2023-07-27 21:38:29 +10:00
590d4640dd Implement craft on benches
Initially just a stove
Also update Rust.
2023-07-24 22:46:50 +10:00
bfc1d4d4b5 Allow putting things in containers.
Includes rules on what you can place, and weight / capacity limits.
2023-07-16 13:37:29 +10:00
b3cbc9f544 Add a bookshop, and add getting things from containers
The book doesn't yet contain recipes, but when it does, we will be
able to get things out of it to use in the non-improv crafting system
(to be implemented).
2023-07-14 23:15:30 +10:00
ea45530a39 Apply limits on carrying capacity, and make roboporter strong. 2023-07-11 21:23:34 +10:00
e83cc19698 Allow hiring NPCs (roboporters) to carry heavy stuff!
Note that I still have to implement carrying weight calculation, so it
isn't yet as useful as it will be eventually!
2023-07-06 22:34:01 +10:00
4b524fda96 Support following other players. 2023-06-30 23:46:38 +10:00
261151881d Refactor to move command queue to item.
This is the start of being able to implement following in a way that
works for NPCs, but it isn't finished yet. It does mean NPCs can do
things like climb immediately, and will make it far simpler for NPCs
to do other player-like actions in the future.
2023-06-20 22:53:46 +10:00
61b40a9000 Fix test that didn't compile. 2023-06-12 00:52:27 +10:00
cd40573345 Add an improvise command to craft things without tools. 2023-06-12 00:36:55 +10:00
3292dcc13b Implement delete command to reset or destroy a character. 2023-06-07 22:38:46 +10:00
862d7e3824 Show wielded and worn in inventory. 2023-06-04 11:57:30 +10:00
228c5fbb9b Add gear command. 2023-06-03 23:47:29 +10:00
cf0d2f740b Apply dodge penalty from armour. 2023-05-28 21:59:09 +10:00
c78dc64a7f Implement soaks
Also fix some minor combat bugs found, including the refocusing bug,
and the dead NPCs talking (mk II) bug.
2023-05-25 22:51:52 +10:00
2747dddd90 Allow buying, wearing and removing clothes.
Step 2 will be to make clothes serve a functional purpose as armour.
2023-05-23 20:37:27 +10:00
79b0ed8540 Implement a climbing system. 2023-05-21 22:31:52 +10:00
078519be95 Add journal system
Also fix up bugs with navigation during death, and awarding payouts when
you don't get any XP.
2023-05-16 22:02:42 +10:00
6ce1aff83e Add butcher command to cut everything from a corpse at once. 2023-04-24 22:55:40 +10:00
26cc053480 Improve cut command to be queued, and always corpsify first. 2023-04-24 16:47:08 +10:00
d8b0b6bed5 Allow cutting parts from corpses. 2023-04-24 00:56:42 +10:00
936fcc6dde Refactor to make death more than a boolean (what organs left) 2023-04-23 22:31:31 +10:00
8102f2f7b0 Document install and uninstall. 2023-04-23 00:34:05 +10:00
10351fdf18 Add consent check on accessing private property. 2023-04-23 00:28:15 +10:00
ed3d77dcbe Allow (un)installation of scanlocks. 2023-04-21 23:33:23 +10:00
3d3f792fdc Support closing doors. 2023-04-16 22:39:34 +10:00
62f5457d3a Open doors automatically on movement. 2023-04-16 22:12:19 +10:00
131512fbf6 Allow doors to open. 2023-04-16 01:54:03 +10:00
284c49b4a1 Add gmap (giant map) command. 2023-04-09 23:51:10 +10:00
b6ed5ea487 Implement lmap command for dynzones. 2023-04-09 23:12:51 +10:00
a78b3b4892 Add vacate command. 2023-04-09 00:36:45 +10:00
0ccf9a3adc Allow renting apartments at CoK building. 2023-04-08 23:51:18 +10:00
ff5e80398a Let people list their current consents. 2023-04-02 12:30:50 +10:00
a9693803ef Write help for corp commands. 2023-04-01 23:31:42 +11:00
1cf13413e8 Allow corp leaders to authorise corp v corp warfare. 2023-03-28 22:31:59 +11:00
7d1d6675b7 Allow corps to consent to fight each other.
Note it doesn't actually do anything yet - that's coming!
2023-03-27 22:41:39 +11:00
d35bbbad53 Implement messaging to corps. 2023-03-26 22:29:39 +11:00
6c86599103 Add corp management commands. 2023-03-26 16:51:10 +11:00
cd0f9661d1 Add corp promote and corp info commands. 2023-03-26 00:51:21 +11:00
3bd0412e4a Allow people to join, leave, and fire from corps. 2023-03-25 00:58:19 +11:00
cb05843ee9 Add ability to invite people to corps. 2023-03-19 22:59:35 +11:00
8084b020c3 Show someones primary corp in who. 2023-03-19 15:41:48 +11:00
929a64f93e Allow creation of new corps. 2023-03-19 00:04:59 +11:00
158b590c35 Revoke 'until death' consent on death. 2023-03-13 22:38:54 +11:00
3f6419e5f8 Enforce consent check on use command. 2023-03-13 18:28:03 +11:00
b000f9830b Split possessions out into separate files per type. 2023-03-13 17:53:41 +11:00
e0187bd2c8 Add documentation on allow. 2023-03-13 17:21:53 +11:00
b96fc2e772 Enable PvP fighting. 2023-03-13 15:23:07 +11:00
73d92e3074 Let items become something else on exhaustion. 2023-03-02 23:25:08 +11:00
caa3d84081 Allow checking score and status. 2023-02-26 22:34:26 +11:00
369b07474a Support paging. 2023-02-26 17:01:05 +11:00
863ba692f4 Possessions eventually expire if dropped in a public place. 2023-02-26 02:01:48 +11:00
8085689490 Fix edge cases + use up charges when using "use" 2023-02-26 00:56:22 +11:00
385d2d1fd8 Implement use command for trauma kits. 2023-02-25 23:49:46 +11:00
70dae5b853 Look shows health and charges. 2023-02-23 22:55:02 +11:00
8e1d15bade Allow partial matches on alias. 2023-02-23 21:33:26 +11:00
a9df230a08 Make inventory show total weight if you have multiple of an item. 2023-02-23 21:12:32 +11:00
9e754881e5 Implement Healthtech Melbs to sell trauma kits etc... 2023-02-22 22:27:29 +11:00
b878f8f95c Sorry NPCs, no last words from beyond the grave. 2023-02-22 21:43:18 +11:00
ed3dcdcb64 Give drop the same enhancements as get, + write help 2023-02-22 21:36:57 +11:00
ea5b5ef70d Display legally required classification notices. 2023-02-20 22:42:51 +11:00
d4fd71d839 Improve get targeting with 'all' option. 2023-02-20 22:27:43 +11:00
ddd0f33cb5 Don't merge wielded items with others. 2023-02-20 17:42:44 +11:00
d5314c981e Add drop command, and show inventory on look. 2023-02-20 17:38:35 +11:00
25cfda033b Implement get command to pick things up. 2023-02-20 16:33:53 +11:00
9046218155 Fixups for attack after attacker leaves. 2023-02-20 15:23:36 +11:00
b28ea26d8a Use correct indefinite article in possession descriptions. 2023-02-20 00:02:29 +11:00
7d8c236f3f Add link to privacy policy. 2023-02-20 00:02:08 +11:00
51b4c5847f Write more tests for capacity. 2023-02-19 14:07:02 +11:00
4652fa52cf Support mocking DB to increase testability. 2023-02-19 14:03:15 +11:00
8d12c88904 Fix bug that stopped counterattack working. 2023-02-19 11:17:05 +11:00
2850b66bee Support wielding weapons. 2023-02-19 01:18:08 +11:00
dc38d87beb Fix bug in deploy script causing missed deploy. 2023-02-18 00:34:30 +11:00
1d04369ef2 Supply staff contact email in place of under development message 2023-02-18 00:24:28 +11:00
61305627e3 More deploy fixups. 2023-02-13 22:37:45 +11:00
95b44ac010 Run git from right directory. 2023-02-13 22:34:46 +11:00
ff43a2d43b Don't deploy when no changes happened 2023-02-13 22:32:00 +11:00
1401292b23 Don't kill process if pidfile has own pid. 2023-02-13 22:05:23 +11:00
0cdf8a8810 Move in exe from same filesystem to trigger inotifywatch 2023-02-13 21:25:00 +11:00
23bf7b1e0f Fix path to archive 2023-02-13 21:11:37 +11:00
1cf79cfbd6 Fix echo with missing quotes 2023-02-13 21:07:47 +11:00
0d5560a659 Display length of deploy key. 2023-02-13 20:49:35 +11:00
078f1818b0 Trust deployer as known host 2023-02-12 23:17:52 +11:00
3d47663a1a Fix tar invocation 2023-02-12 23:10:40 +11:00
dc212ec9db Fix script perms 2023-02-12 23:08:18 +11:00
0008c8b739 Attempt to deploy as part of pipeline. 2023-02-12 23:05:34 +11:00
88bfed0c7b Explicitly set CARGO_HOME 2023-02-12 21:16:55 +11:00
e4df647f1d Improve debug logging to ensure help work out CARGO_HOME caching. 2023-02-12 21:14:56 +11:00
78cd3a26cb Dump structure for debugging. 2023-02-12 21:01:52 +11:00
f74bc7fe6b Cache .cargo, and copy output to separate path. 2023-02-12 18:23:22 +11:00
bc0843adfa Change profile to release 2023-02-12 18:13:56 +11:00
a21219eaa5 Minor fixups. 2023-02-12 18:03:27 +11:00
dd6cd293ca Make build pipeline do cargo build 2023-02-12 18:00:25 +11:00
a985279db3 Expose git version for use from deployment pipeline. 2023-02-11 00:36:09 +11:00
28f4e38e4c Pay out for kill bonuses. 2023-02-10 22:33:50 +11:00
f930d67562 Start of Concourse setup + early start on reward system 2023-02-08 22:35:05 +11:00
f21f574ebb Implement capacity limit system. 2023-02-03 23:26:24 +11:00
99ffe45479 Implement inventory command. 2023-02-02 22:58:20 +11:00
985f93ca08 Implement buying possessions. 2023-01-30 22:28:43 +11:00
722757468f Start work on shops. 2023-01-29 00:26:06 +11:00
2053c9cbb3 Add who command. 2023-01-28 23:00:53 +11:00
29b02407fa Add more help. 2023-01-28 20:58:04 +11:00
737fe3a86f Change parameters to reduce CPU usage growth and reduce message spam 2023-01-28 18:34:32 +11:00
a3ea381438 Support player recloning + NPCs can wander and aggro. 2023-01-27 00:36:49 +11:00
2d9fcf9850 Bugfixes + don't get to keep items when you die. 2023-01-25 23:59:19 +11:00
e6e712e255 Make dead NPCs auto-respawn. 2023-01-23 22:52:01 +11:00
618d88bb06 Adjust parameters to make probability distributions looser
(i.e. outcomes depend more on chance).
2023-01-23 21:40:36 +11:00
165f5671ac Implement health and death. 2023-01-22 22:43:44 +11:00
09db1a6ed9 Implement more combat capability. 2023-01-22 01:16:00 +11:00
c26a4768c5 Add alias system. 2023-01-20 23:38:57 +11:00
0a5b9cc94e Implement grinding. 2023-01-20 23:09:41 +11:00
b2012d4d18 Merge pull request 'Remove unnecessary whitespace' (#1) from Condorra2/blastmud:condorra2-patch-1 into main
Reviewed-on: blasthavers/blastmud#1
2023-01-16 22:33:14 +11:00
a9b5a545e4 Remove unnecessary whitespace 2023-01-16 22:30:21 +11:00
211 changed files with 45834 additions and 7462 deletions

20
.ci/build Executable file
View File

@ -0,0 +1,20 @@
#!/bin/bash -e
set -Eeuo pipefail
export HOME=$(pwd)
export CARGO_HOME=$(pwd)/.cargo
cd blastmud-repo
BUILDSTAMP=$(date +%s)
echo Build timestamp for binary names: $BUILDSTAMP
echo Running tests
cargo test --target-dir ../target --profile release
echo Running main build
cargo build --target-dir ../target --profile release
echo Copying artifacts
echo $BUILDSTAMP >../binaries/buildstamp
cp ../target/release/blastmud_game ../binaries/
cp ../target/release/blastmud_listener ../binaries/

47
.ci/deploy Executable file
View File

@ -0,0 +1,47 @@
#!/bin/bash
set -Eeu
DEPLOY_HOST=172.19.11.5
mkdir -p ~/.ssh
cat >>~/.ssh/known_hosts <<END
172.19.11.5 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCm71djj4D0EX/rW9p0d4t/gIoUMK8kMJEs71KadFUu3Ij2BHqbS3U8G3sjm6qm7nU7c0QD1WNyzLbCp23qroobS2/TOjvewZMe5Gs4iGBmE5VLJHY/hD0AKHermcrv1z7KbLnLaCgqQdGdzqcPs07Z9BdbHBDHq57+VJsIQ9BCt67GevppPyV6sIQX9h6aoLo4179vwQ9tC7fPcp8GzO7PTcixMhWGF0A12YcMxA3OR5q3GoeQxGZ3wRXg7avNJhHuAI3tWIA5VUcn/DTuRC16ndSVuyxous/L+jxNuk7wDIDXRPuOin7edoJ7s4ZXxV7EWPKANZzzmStQzeLX7ew9K6uF1BNmzZArK03ts8H/h6Q/O8KB1/oCnAtpoCMHfDM2AiF9SOAa9S6yBmYFAGXgLV+BdnbZnIpOIE6zyPv3k/c9zzFW7ZoQ9V7VWomCX7FxL6uwoaBoVAeCSVkfrxFoXnzIyrcaGXBUlWftTKpB0jqCdJ/Eifquj9ImIE5CcNk=
172.19.11.5 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBC5PjzIMikn+AJWyvge7DqFsdMarG9CVDov9ITbDwNwucoeEUlNoA3hypyrBeatyRL3Y+jWtPV6uwzlRTKye/BY=
172.19.11.5 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEi7lEzVWCLed6VZgnIpfXF7+bugZ6uZwH2hOkIgtfAv
END
chmod 0600 ~/.ssh/known_hosts
echo "$DEPLOY_KEY" >deploy-key
chmod 0600 ./deploy-key
BUILDSTAMP=$(cat binaries/buildstamp)
tar -cvJf binary-archive-$BUILDSTAMP.tar.xz -C binaries blastmud_game blastmud_listener
scp -i deploy-key binary-archive-$BUILDSTAMP.tar.xz blast@$DEPLOY_HOST:/home/blast/archive/
ssh -i deploy-key blast@$DEPLOY_HOST "find /home/blast/archive -mtime +30 -delete && mkdir -p /home/blast/latest && tar -xvJf /home/blast/archive/binary-archive-$BUILDSTAMP.tar.xz -C /home/blast/latest"
(curl --max-time 10 https://ws.blastmud.org/version || true) >version
cd blastmud-repo
ROOT_COMMIT=$(git rev-list HEAD | tail -n 1)
cd ..
LISTENER_VERSION=$(jq -r .listener_version version || echo ROOT_COMMIT)
GAMESERVER_VERSION=$(jq -r .gameserver_version version || echo ROOT_COMMIT)
cd blastmud-repo
CURRENT_VERSION=$(git rev-parse HEAD)
LISTENER_CHANGES=$(git diff $LISTENER_VERSION $CURRENT_VERSION -- blastmud_listener blastmud_interfaces | wc -l)
GAMESERVER_CHANGES=$(git diff $GAMESERVER_VERSION $CURRENT_VERSION -- blastmud_game blastmud_interfaces | wc -l)
cd ..
echo Listener changes: $LISTENER_CHANGES Gameserver changes: $GAMESERVER_CHANGES
if [[ $GAMESERVER_CHANGES != "0" ]]; then
echo Deploying gameserver
ssh -i deploy-key blast@$DEPLOY_HOST "cp /home/blast/latest/blastmud_game /mnt/gameserver-app/tmp && mv /mnt/gameserver-app/tmp/blastmud_game /mnt/gameserver-app/exe"
else
echo No changes to gameserver, skipping.
fi
if [[ $LISTENER_CHANGES != "0" ]]; then
echo Deploying listener
ssh -i deploy-key blast@$DEPLOY_HOST "cp /home/blast/latest/blastmud_listener /mnt/listener-app/tmp && mv /mnt/listener-app/tmp/blastmud_listener /mnt/listener-app/exe"
else
echo No changes to listener, skipping.
fi

View File

@ -0,0 +1,2 @@
FROM debian:latest
RUN apt-get update && apt-get install -y jq ssh git curl ca-certificates xz-utils && rm -rf /var/lib/apt/lists/*

View File

@ -0,0 +1 @@
A base image for running our deploys. It is used by CI, but isn't auto-deployed by it.

View File

@ -0,0 +1,2 @@
docker build -t blasthavers/deploy-base:latest .
docker push blasthavers/deploy-base:latest

2
.ci/fly-set-pipeline Executable file
View File

@ -0,0 +1,2 @@
#!/bin/sh
exec fly -t bm set-pipeline -c .concourse.yml -p blastmud-build

47
.concourse.yml Normal file
View File

@ -0,0 +1,47 @@
jobs:
- name: build-blastmud-image
public: true
build_log_retention:
days: 365
builds: 50
plan:
- get: blastmud-repo
trigger: true
- task: build
config:
platform: linux
image_resource:
type: registry-image
source:
repository: rust
inputs:
- name: blastmud-repo
caches:
- path: target
- path: .cargo
outputs:
- name: binaries
run:
path: blastmud-repo/.ci/build
- task: deploy
config:
platform: linux
image_resource:
type: registry-image
source:
repository: blasthavers/deploy-base
inputs:
- name: binaries
- name: blastmud-repo
params:
DEPLOY_KEY: ((deploy_key))
run:
path: blastmud-repo/.ci/deploy
resources:
- name: blastmud-repo
type: git
check_every: never
webhook_token: ((webhook_token))
source:
uri: https://git.blastmud.org/blasthavers/blastmud.git
branch: main

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
/target /target
config config
docs/private docs/private
*~

1623
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,16 @@ members = [
"blastmud_listener", "blastmud_listener",
"blastmud_interfaces", "blastmud_interfaces",
"blastmud_game", "blastmud_game",
"ansi_markup",
"ansi_macro", "ansi_macro",
"ansi", "ansi",
] ]
resolver = "2"
[profile.release-with-debug]
inherits = "release"
debug = true
[profile.dev]
lto = false

View File

@ -6,40 +6,6 @@ core written in Rust rather than in any form of softcode. Only user data forms p
Even the map is programmed in a normal text editor, and can be Even the map is programmed in a normal text editor, and can be
tested locally before being deployed to the game. tested locally before being deployed to the game.
# Age verification file
The Blastmud game is for adults only (18 years of age or older). In order to make a complete game, three
components are required:
* This game server codebase - which is publicly available and shareable under a permissive (3-clause BSD-style license). It isn't playable as a game by itself.
* A client. Openly available software such as telnet, tintin++, or mudlet can be used as this component.
* A closed-source age verification file, to be placed as `age-verification.yml` alongside the `gameserver.conf` file. This file is copyrighted with all rights reserved (except for the use by the person it is intended for to run the game) and cannot legally be given to anyone else. The initial author of Blastmud intends to provide an `age-verification.yml` file to anyone I am satisfied is not a minor.
## Why does a Free/Open Source project deliberately include a requirement for a non-Open Source file?
In the jurisdiction where the initial author is based, it is illegal to distribute unclassified or R18+ classified games (defined as playable software / data / some combination of it) to people under 18. Restricting access to all components of the game would be an impediment for easy collaboration on the game.
So a decision was made to only distribute a non-playable Free / Open Source component without restrictions (and to ensure this non-playable component doesn't, by itself, meet the definition of either a computer game or a submittable publication).
## I obtained an `age-verification.yml` from the initial author - can I share it / publish it?
No, this file is licensed solely to you and it is a breach of copyright law to publish it without consent from the initial author. A takedown request for the material might be sent, the shared `age-verification.yml` might be revoked in future versions of the server codebase, and you could even be sued for copyright infringement.
Depending on your jurisdiction, publishing a complete game (including `age-verification.yml`) to people who are under 18 could also be a crime.
If you attempt to use the official Blastmud GitHub project (or any other resources) to share `age-verification.yml` (e.g. through issues or pull requests), the material will be deleted and you will be blocked from further interaction with the project (unless we are satisfied it was accidental).
You are allowed to put it on a computer system / server where it is only accessible to a limited number of people known to you, as long as you have verified all those people are 18 or over, and know not to further distribute the file.
## Can I change / remove the code so it doesn't need `age-verification.yml`?
The license for Blastmud allows you to change the code and redistribute your changes. If you are forking Blastmud to create your own game engine, you could change
the age verification keypair or entirely remove the code. You may not call such a modified game Blastmud. Please be aware that if
you modify the code to create a complete computer game, in some jurisdictions you might have to get your fork classified, and might
have legal obligations not to distribute it to anyone under a certain age.
Regarding the use of official Blastmud resources such as our GitHub project and game server instance: to ensure minors are protected, you must not post versions of Blastmud that disable the checking of `age-verification.yml` (or post any other complete unclassified game or game that is unsuitable for minors of any age), nor post patches, pull requests, or instructions for doing the same. You may be blocked from further interaction with the project if you do this (unless we are satisfied it was accidental).
# Architecture # Architecture
Blastmud consists of the following main components: Blastmud consists of the following main components:
@ -50,7 +16,7 @@ Blastmud consists of the following main components:
# Status # Status
Blastmud is not yet playable, it is under development. Blastmud is under active development, but is currently playable.
# Schema management # Schema management
We only keep the latest version in version control, and use migra (pip3 install migra) to identify changes between We only keep the latest version in version control, and use migra (pip3 install migra) to identify changes between
@ -61,6 +27,6 @@ The latest schema is under `schema`.
Create a user with a secret password, and username `blast`. Create a production database called `blast`. Create a user with a secret password, and username `blast`. Create a production database called `blast`.
To get to the latest schema: To get to the latest schema:
* Run `psql <schema/schema.sql` to create the temporary `blast_schemaonly` database. * Run `psql -d template1 <schema/schema.sql` to create the temporary `blast_schemaonly` database.
* Run `migra "postgres:///blast" "postgres:///blast_schemaonly" > /tmp/update.sql` * Run `migra "postgresql:///blast" "postgresql:///blast_schemaonly" > /tmp/update.sql`
* Check `/tmp/update.sql` and if it looks good, apply it with `psql -u blast -d blast </tmp/update.sql` * Check `/tmp/update.sql` and if it looks good, apply it with `psql -U blast -d blast </tmp/update.sql`

View File

@ -5,3 +5,4 @@ edition = "2021"
[dependencies] [dependencies]
ansi_macro = { path = "../ansi_macro" } ansi_macro = { path = "../ansi_macro" }
nom = "7.1.3"

View File

@ -6,8 +6,10 @@ use std::rc::Rc;
/// escape - so use this for untrusted input that you don't expect /// escape - so use this for untrusted input that you don't expect
/// to contain ansi escapes at all. /// to contain ansi escapes at all.
pub fn ignore_special_characters(input: &str) -> String { pub fn ignore_special_characters(input: &str) -> String {
input.chars().filter(|c| *c == '\t' || *c == '\n' || input
(*c >= ' ' && *c <= '~')).collect() .chars()
.filter(|c| *c == '\t' || *c == '\n' || (*c >= ' ' && *c <= '~'))
.collect()
} }
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
@ -22,26 +24,35 @@ struct AnsiState {
impl AnsiState { impl AnsiState {
fn restore_ansi(self: &Self) -> String { fn restore_ansi(self: &Self) -> String {
let mut buf = String::new(); let mut buf = String::new();
if !(self.bold && self.underline && self.strike && if !(self.bold
self.background != 0 && self.foreground != 0) { && self.underline
&& self.strike
&& self.background != 0
&& self.foreground != 0)
{
buf.push_str(ansi!("<reset>")); buf.push_str(ansi!("<reset>"));
} }
if self.bold { buf.push_str(ansi!("<bold>")); } if self.bold {
if self.underline { buf.push_str(ansi!("<under>")); } buf.push_str(ansi!("<bold>"));
if self.strike { buf.push_str(ansi!("<strike>")); } }
if self.underline {
buf.push_str(ansi!("<under>"));
}
if self.strike {
buf.push_str(ansi!("<strike>"));
}
if self.background != 0 { if self.background != 0 {
buf.push_str(&format!("\x1b[{}m", 39 + self.background)); } buf.push_str(&format!("\x1b[{}m", 39 + self.background));
}
if self.foreground != 0 { if self.foreground != 0 {
buf.push_str(&format!("\x1b[{}m", 29 + self.foreground)); } buf.push_str(&format!("\x1b[{}m", 29 + self.foreground));
}
buf buf
} }
} }
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
struct AnsiEvent<'l> ( struct AnsiEvent<'l>(AnsiParseToken<'l>, Rc<AnsiState>);
AnsiParseToken<'l>,
Rc<AnsiState>
);
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
enum AnsiParseToken<'l> { enum AnsiParseToken<'l> {
@ -63,25 +74,25 @@ struct AnsiIterator<'l> {
inject_spaces: u64, inject_spaces: u64,
} }
impl AnsiIterator<'_> { impl AnsiIterator<'_> {
fn new<'l>(input: &'l str) -> AnsiIterator<'l> { fn new<'l>(input: &'l str) -> AnsiIterator<'l> {
AnsiIterator { underlying: input.chars().enumerate(), AnsiIterator {
input: input, underlying: input.chars().enumerate(),
state: Rc::new(AnsiState { input: input,
background: 0, state: Rc::new(AnsiState {
foreground: 0, background: 0,
bold: false, foreground: 0,
underline: false, bold: false,
strike: false underline: false,
}), strike: false,
pending_col: false, }),
inject_spaces: 0 pending_col: false,
inject_spaces: 0,
} }
} }
} }
impl <'l>Iterator for AnsiIterator<'l> { impl<'l> Iterator for AnsiIterator<'l> {
type Item = AnsiEvent<'l>; type Item = AnsiEvent<'l>;
fn next(self: &mut Self) -> Option<AnsiEvent<'l>> { fn next(self: &mut Self) -> Option<AnsiEvent<'l>> {
@ -91,7 +102,10 @@ impl <'l>Iterator for AnsiIterator<'l> {
if self.inject_spaces > 0 { if self.inject_spaces > 0 {
self.pending_col = true; self.pending_col = true;
self.inject_spaces -= 1; self.inject_spaces -= 1;
return Some(AnsiEvent::<'l>(AnsiParseToken::Character(' '), self.state.clone())); return Some(AnsiEvent::<'l>(
AnsiParseToken::Character(' '),
self.state.clone(),
));
} }
while let Some((i0, c)) = self.underlying.next() { while let Some((i0, c)) = self.underlying.next() {
if c == '\n' { if c == '\n' {
@ -100,11 +114,17 @@ impl <'l>Iterator for AnsiIterator<'l> {
for _ in 0..4 { for _ in 0..4 {
self.pending_col = true; self.pending_col = true;
self.inject_spaces = 3; self.inject_spaces = 3;
return Some(AnsiEvent::<'l>(AnsiParseToken::Character(' '), self.state.clone())); return Some(AnsiEvent::<'l>(
AnsiParseToken::Character(' '),
self.state.clone(),
));
} }
} else if c >= ' ' && c <= '~' { } else if c >= ' ' && c <= '~' {
self.pending_col = true; self.pending_col = true;
return Some(AnsiEvent::<'l>(AnsiParseToken::Character(c), self.state.clone())); return Some(AnsiEvent::<'l>(
AnsiParseToken::Character(c),
self.state.clone(),
));
} else if c == '\x1b' { } else if c == '\x1b' {
if let Some((_, c2)) = self.underlying.next() { if let Some((_, c2)) = self.underlying.next() {
if c2 != '[' { if c2 != '[' {
@ -125,7 +145,9 @@ impl <'l>Iterator for AnsiIterator<'l> {
cs_no *= 10; cs_no *= 10;
cs_no += cs_no2; cs_no += cs_no2;
imax = i3; imax = i3;
} else { continue; } } else {
continue;
}
} }
} else if cs2 != 'm' { } else if cs2 != 'm' {
continue; continue;
@ -141,30 +163,36 @@ impl <'l>Iterator for AnsiIterator<'l> {
st.underline = false; st.underline = false;
st.strike = false; st.strike = false;
} }
1 => { st.bold = true; } 1 => {
4 => { st.underline = true; } st.bold = true;
9 => { st.strike = true; } }
24 => { st.underline = false; } 4 => {
st.underline = true;
}
9 => {
st.strike = true;
}
24 => {
st.underline = false;
}
i if i >= 30 && i <= 37 => { i if i >= 30 && i <= 37 => {
st.foreground = i as u64 - 29; st.foreground = i as u64 - 29;
} }
i if i >= 40 && i <= 47 => { i if i >= 40 && i <= 47 => {
st.foreground = i as u64 - 39; st.foreground = i as u64 - 39;
} }
_ => continue _ => continue,
} }
drop(st);
return Some(AnsiEvent::<'l>( return Some(AnsiEvent::<'l>(
AnsiParseToken::ControlSeq( AnsiParseToken::ControlSeq(&self.input[i0..(imax + 1)]),
&self.input[i0..(imax + 1)] self.state.clone(),
), self.state.clone())); ));
} }
} }
} }
} }
None None
} }
} }
/// Strips out basic colours / character formatting codes cleanly. Tabs are /// Strips out basic colours / character formatting codes cleanly. Tabs are
@ -193,7 +221,7 @@ pub fn limit_special_characters(input: &str) -> String {
match e { match e {
AnsiParseToken::Character(c) => buf.push(c), AnsiParseToken::Character(c) => buf.push(c),
AnsiParseToken::Newline => buf.push('\n'), AnsiParseToken::Newline => buf.push('\n'),
AnsiParseToken::ControlSeq(t) => buf.push_str(t) AnsiParseToken::ControlSeq(t) => buf.push_str(t),
} }
} }
buf buf
@ -201,8 +229,13 @@ pub fn limit_special_characters(input: &str) -> String {
/// Flows a second column around a first column, limiting the width of both /// Flows a second column around a first column, limiting the width of both
/// columns as specified, and adding a gutter. /// columns as specified, and adding a gutter.
pub fn flow_around(col1: &str, col1_width: usize, gutter: &str, pub fn flow_around(
col2: &str, col2_width: usize) -> String { col1: &str,
col1_width: usize,
gutter: &str,
col2: &str,
col2_width: usize,
) -> String {
let mut it1 = AnsiIterator::new(col1).peekable(); let mut it1 = AnsiIterator::new(col1).peekable();
let mut it2 = AnsiIterator::new(col2).peekable(); let mut it2 = AnsiIterator::new(col2).peekable();
@ -212,7 +245,7 @@ pub fn flow_around(col1: &str, col1_width: usize, gutter: &str,
'around_rows: loop { 'around_rows: loop {
match it1.peek() { match it1.peek() {
None => break 'around_rows, None => break 'around_rows,
Some(AnsiEvent(_, st)) => buf.push_str(&st.restore_ansi()) Some(AnsiEvent(_, st)) => buf.push_str(&st.restore_ansi()),
} }
let mut fill_needed: usize = 0; let mut fill_needed: usize = 0;
let mut skip_nl = true; let mut skip_nl = true;
@ -244,7 +277,9 @@ pub fn flow_around(col1: &str, col1_width: usize, gutter: &str,
None => break, None => break,
Some(AnsiEvent(AnsiParseToken::Character(_), _)) => break, Some(AnsiEvent(AnsiParseToken::Character(_), _)) => break,
Some(AnsiEvent(AnsiParseToken::ControlSeq(s), _)) => { Some(AnsiEvent(AnsiParseToken::ControlSeq(s), _)) => {
if fill_needed > 0 { buf.push_str(s); } if fill_needed > 0 {
buf.push_str(s);
}
it1.next(); it1.next();
} }
Some(AnsiEvent(AnsiParseToken::Newline, _)) => { Some(AnsiEvent(AnsiParseToken::Newline, _)) => {
@ -254,7 +289,9 @@ pub fn flow_around(col1: &str, col1_width: usize, gutter: &str,
} }
} }
} }
for _ in 0..fill_needed { buf.push(' '); } for _ in 0..fill_needed {
buf.push(' ');
}
buf.push_str(gutter); buf.push_str(gutter);
@ -285,7 +322,9 @@ pub fn flow_around(col1: &str, col1_width: usize, gutter: &str,
None => break, None => break,
Some(AnsiEvent(AnsiParseToken::Character(_), _)) => break, Some(AnsiEvent(AnsiParseToken::Character(_), _)) => break,
Some(AnsiEvent(AnsiParseToken::ControlSeq(s), _)) => { Some(AnsiEvent(AnsiParseToken::ControlSeq(s), _)) => {
if fill_needed > 0 { buf.push_str(s); } if fill_needed > 0 {
buf.push_str(s);
}
it2.next(); it2.next();
} }
Some(AnsiEvent(AnsiParseToken::Newline, _)) => { Some(AnsiEvent(AnsiParseToken::Newline, _)) => {
@ -303,7 +342,7 @@ pub fn flow_around(col1: &str, col1_width: usize, gutter: &str,
match e { match e {
AnsiParseToken::Character(c) => buf.push(c), AnsiParseToken::Character(c) => buf.push(c),
AnsiParseToken::Newline => buf.push('\n'), AnsiParseToken::Newline => buf.push('\n'),
AnsiParseToken::ControlSeq(t) => buf.push_str(t) AnsiParseToken::ControlSeq(t) => buf.push_str(t),
} }
} }
@ -315,7 +354,9 @@ fn is_wrappable(c: char) -> bool {
} }
pub fn word_wrap<F>(input: &str, limit: F) -> String pub fn word_wrap<F>(input: &str, limit: F) -> String
where F: Fn(usize) -> usize { where
F: Fn(usize) -> usize,
{
let mut it_main = AnsiIterator::new(input); let mut it_main = AnsiIterator::new(input);
let mut start_word = true; let mut start_word = true;
let mut row: usize = 0; let mut row: usize = 0;
@ -338,9 +379,12 @@ pub fn word_wrap<F>(input: &str, limit: F) -> String
let fits = 'check_fits: loop { let fits = 'check_fits: loop {
match it_lookahead.next() { match it_lookahead.next() {
None => break 'check_fits true, None => break 'check_fits true,
Some(AnsiEvent(AnsiParseToken::Newline, _)) => break 'check_fits true, Some(AnsiEvent(AnsiParseToken::Newline, _)) => {
Some(AnsiEvent(AnsiParseToken::Character(c), _)) => break 'check_fits true
break 'check_fits is_wrappable(c), }
Some(AnsiEvent(AnsiParseToken::Character(c), _)) => {
break 'check_fits is_wrappable(c)
}
_ => {} _ => {}
} }
}; };
@ -361,9 +405,13 @@ pub fn word_wrap<F>(input: &str, limit: F) -> String
} }
continue; continue;
} }
assert!(col <= limit(row), assert!(
"col must be below limit, but found c={}, col={}, limit={}", col <= limit(row),
c, col, limit(row)); "col must be below limit, but found c={}, col={}, limit={}",
c,
col,
limit(row)
);
if !start_word { if !start_word {
if col == limit(row) { if col == limit(row) {
// We are about to hit the limit, and we need to decide // We are about to hit the limit, and we need to decide
@ -372,9 +420,12 @@ pub fn word_wrap<F>(input: &str, limit: F) -> String
let fits = 'check_fits: loop { let fits = 'check_fits: loop {
match it_lookahead.next() { match it_lookahead.next() {
None => break 'check_fits true, None => break 'check_fits true,
Some(AnsiEvent(AnsiParseToken::Newline, _)) => break 'check_fits true, Some(AnsiEvent(AnsiParseToken::Newline, _)) => {
Some(AnsiEvent(AnsiParseToken::Character(c), _)) => break 'check_fits true
break 'check_fits is_wrappable(c), }
Some(AnsiEvent(AnsiParseToken::Character(c), _)) => {
break 'check_fits is_wrappable(c)
}
_ => {} _ => {}
} }
}; };
@ -429,7 +480,7 @@ pub fn word_wrap<F>(input: &str, limit: F) -> String
buf.push('\n'); buf.push('\n');
start_word = true; start_word = true;
} }
Some(AnsiEvent(AnsiParseToken::ControlSeq(t), _)) => buf.push_str(t) Some(AnsiEvent(AnsiParseToken::ControlSeq(t), _)) => buf.push_str(t),
} }
} }
@ -450,24 +501,26 @@ mod test {
assert_eq!(strip_special_characters("a\tb"), "a b"); assert_eq!(strip_special_characters("a\tb"), "a b");
assert_eq!( assert_eq!(
strip_special_characters(ansi!("<red>hello<green>world")), strip_special_characters(ansi!("<red>hello<green>world")),
"helloworld"); "helloworld"
);
assert_eq!( assert_eq!(
strip_special_characters("hello\r\x07world\n"), strip_special_characters("hello\r\x07world\n"),
"helloworld\n"); "helloworld\n"
);
assert_eq!( assert_eq!(
strip_special_characters("hello\r\x07world\n"), strip_special_characters("hello\r\x07world\n"),
"helloworld\n"); "helloworld\n"
assert_eq!( );
strip_special_characters("Test\x1b[5;5fing"), assert_eq!(strip_special_characters("Test\x1b[5;5fing"), "Test5fing");
"Test5fing");
} }
#[test] #[test]
fn limit_special_characters_strips_some_things() { fn limit_special_characters_strips_some_things() {
assert_eq!(limit_special_characters(ansi!("a<bgred><green>b<bggreen><red>c<reset>d")), assert_eq!(
ansi!("a<bgred><green>b<bggreen><red>c<reset>d")); limit_special_characters(ansi!("a<bgred><green>b<bggreen><red>c<reset>d")),
assert_eq!(limit_special_characters("Test\x1b[5;5fing"), ansi!("a<bgred><green>b<bggreen><red>c<reset>d")
"Test5fing"); );
assert_eq!(limit_special_characters("Test\x1b[5;5fing"), "Test5fing");
} }
#[test] #[test]
@ -537,5 +590,4 @@ mod test {
- -testing"; - -testing";
assert_eq!(word_wrap(unwrapped, |_| 10), wrapped); assert_eq!(word_wrap(unwrapped, |_| 10), wrapped);
} }
} }

View File

@ -7,6 +7,7 @@ edition = "2021"
proc-macro = true proc-macro = true
[dependencies] [dependencies]
nom = "7.1.1" nom = "7.1.3"
quote = "1.0.23" quote = "1.0.35"
syn = "1.0.107" syn = "2.0.52"
ansi_markup = { path = "../ansi_markup" }

View File

@ -1,64 +1,17 @@
use ansi_markup::parse_ansi_markup;
use proc_macro::TokenStream; use proc_macro::TokenStream;
use syn::{parse_macro_input, Lit};
use quote::ToTokens; use quote::ToTokens;
use nom::{ use syn::{parse_macro_input, Lit};
combinator::eof,
branch::alt, multi::fold_many0,
bytes::complete::{take_till, take_till1, tag},
sequence::{tuple, pair},
error::Error,
Err,
Parser
};
enum AnsiFrag<'l> {
Lit(&'l str),
Special(&'l str)
}
use AnsiFrag::Special;
#[proc_macro] #[proc_macro]
pub fn ansi(input: TokenStream) -> TokenStream { pub fn ansi(input: TokenStream) -> TokenStream {
let raw = match parse_macro_input!(input as Lit) { let raw = match parse_macro_input!(input as Lit) {
Lit::Str(lit_str) => lit_str.value(), Lit::Str(lit_str) => lit_str.value(),
_ => panic!("Expected a string literal") _ => panic!("Expected a string literal"),
}; };
fn parser(i: &str) -> Result<String, Err<Error<&str>>> { TokenStream::from(
pair(fold_many0( parse_ansi_markup(&raw)
alt(( .unwrap_or_else(|e| panic!("Bad ansi literal: {}", e))
take_till1(|c| c == '<').map(AnsiFrag::Lit), .into_token_stream(),
tuple((tag("<"), take_till(|c| c == '>'), tag(">"))).map(|t| AnsiFrag::Special(t.1)) )
)),
|| "".to_owned(),
|a, r| a + match r {
AnsiFrag::Lit(s) => &s,
Special(s) if s == "reset" => "\x1b[0m",
Special(s) if s == "bold" => "\x1b[1m",
Special(s) if s == "under" => "\x1b[4m",
Special(s) if s == "strike" => "\x1b[9m",
Special(s) if s == "nounder" => "\x1b[24m",
Special(s) if s == "black" => "\x1b[30m",
Special(s) if s == "red" => "\x1b[31m",
Special(s) if s == "green" => "\x1b[32m",
Special(s) if s == "yellow" => "\x1b[33m",
Special(s) if s == "blue" => "\x1b[34m",
Special(s) if s == "magenta" => "\x1b[35m",
Special(s) if s == "cyan" => "\x1b[36m",
Special(s) if s == "white" => "\x1b[37m",
Special(s) if s == "bgblack" => "\x1b[40m",
Special(s) if s == "bgred" => "\x1b[41m",
Special(s) if s == "bggreen" => "\x1b[42m",
Special(s) if s == "bgyellow" => "\x1b[43m",
Special(s) if s == "bgblue" => "\x1b[44m",
Special(s) if s == "bgmagenta" => "\x1b[45m",
Special(s) if s == "bgcyan" => "\x1b[46m",
Special(s) if s == "bgwhite" => "\x1b[47m",
Special(s) if s == "lt" => "<",
Special(r) => panic!("Unknown ansi type {}", r)
}
), eof)(i).map(|(_, (r, _))| r)
}
TokenStream::from(parser(&raw)
.unwrap_or_else(|e| { panic!("Bad ansi literal: {}", e) })
.into_token_stream())
} }

7
ansi_markup/Cargo.toml Normal file
View File

@ -0,0 +1,7 @@
[package]
name = "ansi_markup"
version = "0.1.0"
edition = "2021"
[dependencies]
nom = "7.1.3"

62
ansi_markup/src/lib.rs Normal file
View File

@ -0,0 +1,62 @@
use nom::{
branch::alt,
bytes::complete::{tag, take_till, take_till1},
combinator::eof,
error::Error,
multi::fold_many0,
sequence::{pair, tuple},
Err, Parser,
};
enum AnsiFrag<'l> {
Lit(&'l str),
Special(&'l str),
}
use AnsiFrag::Special;
pub fn parse_ansi_markup(i: &str) -> Result<String, Err<Error<&str>>> {
pair(
fold_many0(
alt((
take_till1(|c| c == '<').map(AnsiFrag::Lit),
tuple((tag("<"), take_till(|c| c == '>'), tag(">")))
.map(|t| AnsiFrag::Special(t.1)),
)),
|| "".to_owned(),
|a, r| {
a + match r {
AnsiFrag::Lit(s) => &s,
Special(s) if s == "reset" => "\x1b[0m",
Special(s) if s == "bold" => "\x1b[1m",
Special(s) if s == "under" => "\x1b[4m",
Special(s) if s == "strike" => "\x1b[9m",
Special(s) if s == "nounder" => "\x1b[24m",
Special(s) if s == "black" => "\x1b[30m",
Special(s) if s == "red" => "\x1b[31m",
Special(s) if s == "green" => "\x1b[32m",
Special(s) if s == "yellow" => "\x1b[33m",
Special(s) if s == "blue" => "\x1b[34m",
Special(s) if s == "magenta" => "\x1b[35m",
Special(s) if s == "cyan" => "\x1b[36m",
Special(s) if s == "white" => "\x1b[37m",
Special(s) if s == "bgblack" => "\x1b[40m",
Special(s) if s == "bgred" => "\x1b[41m",
Special(s) if s == "bggreen" => "\x1b[42m",
Special(s) if s == "bgyellow" => "\x1b[43m",
Special(s) if s == "bgblue" => "\x1b[44m",
Special(s) if s == "bgmagenta" => "\x1b[45m",
Special(s) if s == "bgcyan" => "\x1b[46m",
Special(s) if s == "bgwhite" => "\x1b[47m",
Special(s) if s == "lt" => "<",
Special(r) => panic!("Unknown ansi type {}", r),
}
},
),
eof,
)(i)
.map(|(_, (r, _))| r)
}
pub fn parse_ansi_markup_escape(i: &str) -> Result<String, Err<Error<&str>>> {
parse_ansi_markup(i).map(|o| o.replace('\x1b', "\\e"))
}

View File

@ -6,33 +6,46 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
base64 = "0.20.0" base64 = "0.21.7"
blastmud_interfaces = { path = "../blastmud_interfaces" } blastmud_interfaces = { path = "../blastmud_interfaces" }
ansi = { path = "../ansi" } ansi = { path = "../ansi" }
deadpool = "0.9.5" ansi_markup = { path = "../ansi_markup" }
deadpool-postgres = { version = "0.10.3", features = ["serde"] } deadpool = "0.10.0"
futures = "0.3.25" deadpool-postgres = { version = "0.12.1", features = ["serde"] }
log = "0.4.17" futures = "0.3.30"
nix = "0.26.1" log = "0.4.21"
ring = "0.16.20" nix = { version = "0.27.1", features = ["process", "signal"] }
serde = { version = "1.0.150", features = ["derive", "serde_derive"] } ring = "0.17.8"
serde_yaml = "0.9.14" serde = { version = "1.0.197", features = ["derive", "serde_derive"] }
simple_logger = "4.0.0" serde_yaml = "0.9.32"
tokio = { version = "1.23.0", features = ["signal", "net", "macros", "rt-multi-thread", "rt", "tokio-macros", "time", "sync", "io-util"] } simple_logger = "4.3.3"
tokio-postgres = { version = "0.7.7", features = ["with-uuid-1", "with-serde_json-1"] } tokio = { version = "1.36.0", features = ["signal", "net", "macros", "rt-multi-thread", "rt", "tokio-macros", "time", "sync", "io-util"] }
tokio-postgres = { version = "0.7.10", features = ["with-uuid-1", "with-serde_json-1"] }
tokio-serde = { version = "0.8.0", features = ["serde", "serde_cbor", "cbor"] } tokio-serde = { version = "0.8.0", features = ["serde", "serde_cbor", "cbor"] }
tokio-stream = "0.1.11" tokio-stream = "0.1.14"
tokio-util = { version = "0.7.4", features = ["codec"] } tokio-util = { version = "0.7.10", features = ["codec"] }
uuid = { version = "1.2.2", features = ["v4", "serde", "rng"] } uuid = { version = "1.7.0", features = ["v4", "serde", "rng"] }
serde_json = "1.0.91" serde_json = "1.0.114"
phf = { version = "0.11.1", features = ["macros"] } async-trait = "0.1.77"
async-trait = "0.1.60" nom = "7.1.3"
nom = "7.1.1" ouroboros = "0.18.3"
ouroboros = "0.15.5" chrono = { version = "0.4.35", features = ["serde"] }
chrono = { version = "0.4.23", features = ["serde"] } bcrypt = "0.15.0"
bcrypt = "0.13.0" validator = "0.16.1"
validator = "0.16.0" itertools = "0.12.1"
itertools = "0.10.5" once_cell = "1.19.0"
once_cell = "1.16.0"
rand = "0.8.5" rand = "0.8.5"
async-recursion = "1.0.0" async-recursion = "1.0.5"
rand_distr = "0.4.3"
humantime = "2.1.0"
rust_decimal = "1.34.3"
mockall_double = "0.3.1"
[dev-dependencies]
tokio-test = "0.4.3"
mockall = "0.12.1"
[features]
default = []
# Export data to YAML files in /tmp on startup
yamldump = []

13
blastmud_game/build.rs Normal file
View File

@ -0,0 +1,13 @@
use std::process::Command;
pub fn main() {
let cmdout = Command::new("git")
.arg("rev-parse")
.arg("HEAD")
.output()
.expect("git rev-parse HEAD failed");
println!(
"cargo:rustc-env=GIT_VERSION={}",
String::from_utf8(cmdout.stdout).expect("git revision not UTF-8")
);
}

View File

@ -0,0 +1 @@
edition = "2021"

View File

@ -1,38 +0,0 @@
use std::fs;
use std::error::Error;
use serde::Deserialize;
use ring::signature;
use base64;
use crate::DResult;
#[derive(Deserialize)]
struct AV {
copyright: String,
serial: u64,
cn: String,
assertion: String,
sig: String
}
static KEY_BYTES: [u8;65] = [
0x04, 0x4f, 0xa0, 0x8b, 0x32, 0xa7, 0x7f, 0xc1, 0x0a, 0xfc, 0x51, 0x95, 0x93, 0x57, 0x05,
0xb3, 0x0f, 0xad, 0x16, 0x05, 0x3c, 0x7c, 0xfc, 0x02, 0xd2, 0x7a, 0x63, 0xff, 0xd3, 0x09,
0xaa, 0x5b, 0x78, 0xfe, 0xa8, 0xc2, 0xc3, 0x02, 0xc2, 0xe6, 0xaf, 0x81, 0xc7, 0xa3, 0x03,
0xfa, 0x4d, 0xf1, 0xf9, 0xfc, 0x0a, 0x36, 0xef, 0x6b, 0x1e, 0x9d, 0xce, 0x6e, 0x60, 0xc6,
0xa8, 0xb3, 0x02, 0x35, 0x7e
];
pub fn check() -> DResult<()> {
let av: AV = serde_yaml::from_str(&fs::read_to_string("age-verification.yml")?).
map_err(|error| Box::new(error) as Box<dyn Error + Send + Sync>)?;
if av.copyright != "This file is protected by copyright and may not be used or reproduced except as authorised by the copyright holder. All rights reserved." ||
av.assertion != "age>=18" {
Err(Box::<dyn Error + Send + Sync>::from("Invalid age-verification.yml"))?;
}
let sign_text = format!("cn={};{};serial={}", av.cn, av.assertion, av.serial);
let key: signature::UnparsedPublicKey<&[u8]> =
signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_ASN1, &KEY_BYTES);
key.verify(&sign_text.as_bytes(), &base64::decode(av.sig)?)
.map_err(|_| Box::<dyn Error + Send + Sync>::from("Invalid age-verification.yml signature"))
}

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use rust_decimal::Decimal;
struct PluralRule<'l> { struct PluralRule<'l> {
match_suffix: &'l str, match_suffix: &'l str,
@ -6,51 +7,262 @@ struct PluralRule<'l> {
append_suffix: &'l str, append_suffix: &'l str,
} }
pub fn pluralise(input: &str) -> String { pub fn pluralise(orig_input: &str) -> String {
let mut extra_suffix: &str = "";
let mut input: &str = orig_input;
'wordsplit: for split_word in vec!["pair", "box", "jar", "tube"] {
for (idx, _) in input.match_indices(split_word) {
let end_idx = idx + split_word.len();
if end_idx == input.len() {
continue;
}
if (idx == 0 || &input[idx - 1..idx] == " ") && &input[end_idx..end_idx + 1] == " " {
extra_suffix = &input[end_idx..];
input = &input[0..end_idx];
break 'wordsplit;
}
}
}
static PLURAL_RULES: OnceCell<Vec<PluralRule>> = OnceCell::new(); static PLURAL_RULES: OnceCell<Vec<PluralRule>> = OnceCell::new();
let plural_rules = PLURAL_RULES.get_or_init(|| vec!( let plural_rules = PLURAL_RULES.get_or_init(|| {
PluralRule { match_suffix: "foot", drop: 3, append_suffix: "eet" }, vec![
PluralRule { match_suffix: "tooth", drop: 4, append_suffix: "eeth" }, PluralRule {
PluralRule { match_suffix: "man", drop: 2, append_suffix: "en" }, match_suffix: "foot",
PluralRule { match_suffix: "mouse", drop: 4, append_suffix: "ice" }, drop: 3,
PluralRule { match_suffix: "louse", drop: 4, append_suffix: "ice" }, append_suffix: "eet",
PluralRule { match_suffix: "fish", drop: 0, append_suffix: "" }, },
PluralRule { match_suffix: "sheep", drop: 0, append_suffix: "" }, PluralRule {
PluralRule { match_suffix: "deer", drop: 0, append_suffix: "" }, match_suffix: "tooth",
PluralRule { match_suffix: "pox", drop: 0, append_suffix: "" }, drop: 4,
PluralRule { match_suffix: "cis", drop: 2, append_suffix: "es" }, append_suffix: "eeth",
PluralRule { match_suffix: "sis", drop: 2, append_suffix: "es" }, },
PluralRule { match_suffix: "xis", drop: 2, append_suffix: "es" }, PluralRule {
PluralRule { match_suffix: "ss", drop: 0, append_suffix: "es" }, match_suffix: "man",
PluralRule { match_suffix: "ch", drop: 0, append_suffix: "es" }, drop: 2,
PluralRule { match_suffix: "sh", drop: 0, append_suffix: "es" }, append_suffix: "en",
PluralRule { match_suffix: "ife", drop: 2, append_suffix: "ves" }, },
PluralRule { match_suffix: "lf", drop: 1, append_suffix: "ves" }, PluralRule {
PluralRule { match_suffix: "arf", drop: 1, append_suffix: "ves" }, match_suffix: "mouse",
PluralRule { match_suffix: "ay", drop: 0, append_suffix: "s" }, drop: 4,
PluralRule { match_suffix: "ey", drop: 0, append_suffix: "s" }, append_suffix: "ice",
PluralRule { match_suffix: "iy", drop: 0, append_suffix: "s" }, },
PluralRule { match_suffix: "oy", drop: 0, append_suffix: "s" }, PluralRule {
PluralRule { match_suffix: "uy", drop: 0, append_suffix: "s" }, match_suffix: "louse",
PluralRule { match_suffix: "y", drop: 1, append_suffix: "ies" }, drop: 4,
PluralRule { match_suffix: "ao", drop: 0, append_suffix: "s" }, append_suffix: "ice",
PluralRule { match_suffix: "eo", drop: 0, append_suffix: "s" }, },
PluralRule { match_suffix: "io", drop: 0, append_suffix: "s" }, PluralRule {
PluralRule { match_suffix: "oo", drop: 0, append_suffix: "s" }, match_suffix: "fish",
PluralRule { match_suffix: "uo", drop: 0, append_suffix: "s" }, drop: 0,
// The o rule could be much larger... we'll add specific exceptions as append_suffix: "",
// the come up. },
PluralRule { match_suffix: "o", drop: 0, append_suffix: "es" }, PluralRule {
// Lots of possible exceptions here. match_suffix: "sheep",
PluralRule { match_suffix: "ex", drop: 0, append_suffix: "es" }, drop: 0,
)); append_suffix: "",
},
PluralRule {
match_suffix: "deer",
drop: 0,
append_suffix: "",
},
PluralRule {
match_suffix: "pox",
drop: 0,
append_suffix: "",
},
PluralRule {
match_suffix: "cis",
drop: 2,
append_suffix: "es",
},
PluralRule {
match_suffix: "sis",
drop: 2,
append_suffix: "es",
},
PluralRule {
match_suffix: "xis",
drop: 2,
append_suffix: "es",
},
PluralRule {
match_suffix: "ss",
drop: 0,
append_suffix: "es",
},
PluralRule {
match_suffix: "ch",
drop: 0,
append_suffix: "es",
},
PluralRule {
match_suffix: "sh",
drop: 0,
append_suffix: "es",
},
PluralRule {
match_suffix: "ife",
drop: 2,
append_suffix: "ves",
},
PluralRule {
match_suffix: "lf",
drop: 1,
append_suffix: "ves",
},
PluralRule {
match_suffix: "arf",
drop: 1,
append_suffix: "ves",
},
PluralRule {
match_suffix: "ay",
drop: 0,
append_suffix: "s",
},
PluralRule {
match_suffix: "ey",
drop: 0,
append_suffix: "s",
},
PluralRule {
match_suffix: "iy",
drop: 0,
append_suffix: "s",
},
PluralRule {
match_suffix: "oy",
drop: 0,
append_suffix: "s",
},
PluralRule {
match_suffix: "uy",
drop: 0,
append_suffix: "s",
},
PluralRule {
match_suffix: "y",
drop: 1,
append_suffix: "ies",
},
PluralRule {
match_suffix: "ao",
drop: 0,
append_suffix: "s",
},
PluralRule {
match_suffix: "eo",
drop: 0,
append_suffix: "s",
},
PluralRule {
match_suffix: "io",
drop: 0,
append_suffix: "s",
},
PluralRule {
match_suffix: "oo",
drop: 0,
append_suffix: "s",
},
PluralRule {
match_suffix: "uo",
drop: 0,
append_suffix: "s",
},
// The o rule could be much larger... we'll add specific exceptions as
// the come up.
PluralRule {
match_suffix: "o",
drop: 0,
append_suffix: "es",
},
// Lots of possible exceptions here.
PluralRule {
match_suffix: "ex",
drop: 0,
append_suffix: "es",
},
PluralRule {
match_suffix: "ox",
drop: 0,
append_suffix: "es",
},
]
});
for rule in plural_rules { for rule in plural_rules {
if input.ends_with(rule.match_suffix) { if input.ends_with(rule.match_suffix) {
return input[0..(input.len() - rule.drop)].to_owned() + rule.append_suffix; return input[0..(input.len() - rule.drop)].to_owned()
+ rule.append_suffix
+ extra_suffix;
} }
} }
input.to_owned() + "s" input.to_owned() + "s" + extra_suffix
}
pub fn indefinite_article(countable_word: &str) -> &'static str {
if countable_word.is_empty() {
return "";
}
let vowels = ["a", "e", "i", "o", "u"];
if !vowels.contains(&&countable_word[0..1]) {
if countable_word.starts_with("honor")
|| countable_word.starts_with("honour")
|| countable_word.starts_with("honest")
|| countable_word.starts_with("hour")
|| countable_word.starts_with("heir")
{
return "an";
}
return "a";
}
if countable_word.starts_with("eu")
|| countable_word.starts_with("one")
|| countable_word.starts_with("once")
{
return "a";
}
if countable_word.starts_with("e") {
if countable_word.starts_with("ewe") {
return "a";
}
return "an";
}
if countable_word.starts_with("u") {
if countable_word.len() < 3 {
return "an";
}
if countable_word.starts_with("uni") {
if countable_word.starts_with("unid")
|| countable_word.starts_with("unim")
|| countable_word.starts_with("unin")
{
// unidentified, unimaginable, uninhabited etc...
return "an";
}
// Words like unilateral
return "a";
}
if countable_word.starts_with("unani") || countable_word.starts_with("ubiq") {
return "a";
}
if ["r", "s", "t"].contains(&&countable_word[1..2]) {
if vowels.contains(&&countable_word[2..3]) {
// All u[rst][aeiou] words, e.g. usury, need "a"
return "a";
}
return "an";
}
if countable_word.starts_with("ubiq")
|| countable_word.starts_with("uku")
|| countable_word.starts_with("ukr")
{
return "a";
}
}
return "an";
} }
pub fn caps_first(inp: &str) -> String { pub fn caps_first(inp: &str) -> String {
@ -65,7 +277,26 @@ pub fn join_words(words: &[&str]) -> String {
match words.split_last() { match words.split_last() {
None => "".to_string(), None => "".to_string(),
Some((last, [])) => last.to_string(), Some((last, [])) => last.to_string(),
Some((last, rest)) => rest.join(", ") + " and " + last Some((last, rest)) => rest.join(", ") + " and " + last,
}
}
pub fn join_words_or(words: &[&str]) -> String {
match words.split_last() {
None => "".to_string(),
Some((last, [])) => last.to_string(),
Some((last, rest)) => rest.join(", ") + " or " + last,
}
}
pub fn weight(grams: u64) -> String {
if grams > 999 {
format!(
"{} kg",
Decimal::from_i128_with_scale(grams as i128, 3).normalize()
)
} else {
format!("{} g", grams)
} }
} }
@ -73,7 +304,7 @@ pub fn join_words(words: &[&str]) -> String {
mod test { mod test {
#[test] #[test]
fn pluralise_should_follow_english_rules() { fn pluralise_should_follow_english_rules() {
for (word, plural) in vec!( for (word, plural) in vec![
("cat", "cats"), ("cat", "cats"),
("wolf", "wolves"), ("wolf", "wolves"),
("scarf", "scarves"), ("scarf", "scarves"),
@ -88,33 +319,94 @@ mod test {
("killer blowfly", "killer blowflies"), ("killer blowfly", "killer blowflies"),
("house mouse", "house mice"), ("house mouse", "house mice"),
("zombie sheep", "zombie sheep"), ("zombie sheep", "zombie sheep"),
) { ("brown pair of pants", "brown pairs of pants"),
("good pair", "good pairs"),
("repair kit", "repair kits"),
("box of wolves", "boxes of wolves"),
("jar of acid", "jars of acid"),
] {
assert_eq!(super::pluralise(word), plural); assert_eq!(super::pluralise(word), plural);
} }
} }
#[test]
fn indefinite_article_should_follow_english_rules() {
for (article, word) in vec![
("a", "cat"),
("a", "human"),
("an", "apple"),
("an", "easter egg"),
("an", "indigo orb"),
("an", "orange"),
("an", "urchin"),
("an", "hour"),
("a", "once-in-a-lifetime opportunity"),
("a", "uranium-covered field"),
("a", "usurper to the throne"),
("a", "Ukrainian hero"),
("a", "universal truth"),
("an", "uninvited guest"),
("a", "unanimous decision"),
("a", "European getaway"),
("an", "utter disaster"),
("a", "uterus"),
("a", "user"),
("a", "ubiquitous hazard"),
("a", "unitary plan"),
] {
let result = super::indefinite_article(&word.to_lowercase());
assert_eq!(
format!("{} {}", result, word),
format!("{} {}", article, word)
);
}
}
#[test] #[test]
fn caps_first_works() { fn caps_first_works() {
for (inp, outp) in vec!( for (inp, outp) in vec![
("", ""), ("", ""),
("cat", "Cat"), ("cat", "Cat"),
("Cat", "Cat"), ("Cat", "Cat"),
("hello world", "Hello world"), ("hello world", "Hello world"),
) { ] {
assert_eq!(super::caps_first(inp), outp); assert_eq!(super::caps_first(inp), outp);
} }
} }
#[test] #[test]
fn join_words_works() { fn join_words_works() {
for (inp, outp) in vec!( for (inp, outp) in vec![
(vec!(), ""), (vec![], ""),
(vec!("cat"), "cat"), (vec!["cat"], "cat"),
(vec!("cat", "dog"), "cat and dog"), (vec!["cat", "dog"], "cat and dog"),
(vec!("cat", "dog", "fish"), "cat, dog and fish"), (vec!["cat", "dog", "fish"], "cat, dog and fish"),
(vec!("wolf", "cat", "dog", "fish"), "wolf, cat, dog and fish"), (
) { vec!["wolf", "cat", "dog", "fish"],
"wolf, cat, dog and fish",
),
] {
assert_eq!(super::join_words(&inp[..]), outp); assert_eq!(super::join_words(&inp[..]), outp);
} }
} }
#[test]
fn join_words_or_works() {
for (inp, outp) in vec![
(vec![], ""),
(vec!["cat"], "cat"),
(vec!["cat", "dog"], "cat or dog"),
(vec!["cat", "dog", "fish"], "cat, dog or fish"),
(vec!["wolf", "cat", "dog", "fish"], "wolf, cat, dog or fish"),
] {
assert_eq!(super::join_words_or(&inp[..]), outp);
}
}
#[test]
fn weight_works() {
assert_eq!(super::weight(100), "100 g");
assert_eq!(super::weight(1000), "1 kg");
assert_eq!(super::weight(1100), "1.1 kg");
}
} }

View File

@ -1,40 +1,41 @@
use tokio::{task, time}; use crate::DResult;
use tokio::net::{TcpSocket, TcpStream, lookup_host};
use log::{info, warn};
use tokio_util::codec;
use tokio_util::codec::length_delimited::LengthDelimitedCodec;
use tokio_serde::formats::Cbor;
use blastmud_interfaces::*; use blastmud_interfaces::*;
use futures::prelude::*; use futures::prelude::*;
use tokio::sync::{Mutex, mpsc, oneshot}; use log::{info, warn};
use std::collections::BTreeMap;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid;
use std::collections::BTreeMap;
use crate::DResult;
use std::time::Instant; use std::time::Instant;
use tokio::net::{lookup_host, TcpSocket, TcpStream};
use tokio::sync::{mpsc, oneshot, Mutex};
use tokio::{task, time};
use tokio_serde::formats::Cbor;
use tokio_util::codec;
use tokio_util::codec::length_delimited::LengthDelimitedCodec;
use uuid::Uuid;
#[derive(Debug)] #[derive(Debug)]
pub struct ListenerSend { pub struct ListenerSend {
pub message: MessageToListener, pub message: MessageToListener,
pub ack_notify: oneshot::Sender<()> pub ack_notify: oneshot::Sender<()>,
} }
pub type ListenerMap = Arc<Mutex<BTreeMap<Uuid, mpsc::Sender<ListenerSend>>>>; pub type ListenerMap = Arc<Mutex<BTreeMap<Uuid, mpsc::Sender<ListenerSend>>>>;
async fn handle_from_listener<FHandler, HandlerFut>( async fn handle_from_listener<FHandler, HandlerFut>(
conn: TcpStream, conn: TcpStream,
message_handler: FHandler, message_handler: FHandler,
listener_map: ListenerMap) listener_map: ListenerMap,
where ) where
FHandler: Fn(Uuid, MessageFromListener) -> HandlerFut + Send + 'static, FHandler: Fn(Uuid, MessageFromListener) -> HandlerFut + Send + 'static,
HandlerFut: Future<Output = DResult<()>> + Send + 'static { HandlerFut: Future<Output = DResult<()>> + Send + 'static,
{
let mut conn_framed = tokio_serde::Framed::new( let mut conn_framed = tokio_serde::Framed::new(
codec::Framed::new(conn, LengthDelimitedCodec::new()), codec::Framed::new(conn, LengthDelimitedCodec::new()),
Cbor::<MessageFromListener, MessageToListener>::default() Cbor::<MessageFromListener, MessageToListener>::default(),
); );
let listener_id = match conn_framed.try_next().await { let listener_id = match conn_framed.try_next().await {
Ok(Some(ref msg@MessageFromListener::ListenerPing { uuid })) => { Ok(Some(ref msg @ MessageFromListener::ListenerPing { uuid })) => {
let handle_fut = message_handler(uuid.clone(), msg.clone()); let handle_fut = message_handler(uuid.clone(), msg.clone());
match handle_fut.await { match handle_fut.await {
Ok(_) => {} Ok(_) => {}
@ -43,7 +44,7 @@ where
} }
}; };
uuid uuid
}, }
Ok(Some(msg)) => { Ok(Some(msg)) => {
warn!("Got non-ping first message from listener: {:?}", msg); warn!("Got non-ping first message from listener: {:?}", msg);
return; return;
@ -53,15 +54,24 @@ where
return; return;
} }
Err(e) => { Err(e) => {
warn!("Lost listener connection to error {} before first message", e); warn!(
"Lost listener connection to error {} before first message",
e
);
return; return;
} }
}; };
match conn_framed.send(MessageToListener::AcknowledgeMessage).await { match conn_framed
.send(MessageToListener::AcknowledgeMessage)
.await
{
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
warn!("Got error sending listener acknowledge for initial ping: {}", e); warn!(
"Got error sending listener acknowledge for initial ping: {}",
e
);
return; return;
} }
} }
@ -189,17 +199,20 @@ pub fn make_listener_map() -> ListenerMap {
pub async fn start_listener<FHandler, HandlerFut>( pub async fn start_listener<FHandler, HandlerFut>(
bind_to: String, bind_to: String,
listener_map: ListenerMap, listener_map: ListenerMap,
handle_message: FHandler handle_message: FHandler,
) -> DResult<()> ) -> DResult<()>
where where
FHandler: Fn(Uuid, MessageFromListener) -> HandlerFut + Send + Clone + 'static, FHandler: Fn(Uuid, MessageFromListener) -> HandlerFut + Send + Clone + 'static,
HandlerFut: Future<Output = DResult<()>> + Send + 'static HandlerFut: Future<Output = DResult<()>> + Send + 'static,
{ {
info!("Starting listener on {}", bind_to); info!("Starting listener on {}", bind_to);
let addr = lookup_host(bind_to).await?.next().expect("listener address didn't resolve"); let addr = lookup_host(bind_to)
.await?
.next()
.expect("listener address didn't resolve");
let socket = match addr { let socket = match addr {
SocketAddr::V4 {..} => TcpSocket::new_v4()?, SocketAddr::V4 { .. } => TcpSocket::new_v4()?,
SocketAddr::V6 {..} => TcpSocket::new_v6()? SocketAddr::V6 { .. } => TcpSocket::new_v6()?,
}; };
socket.set_reuseaddr(true)?; socket.set_reuseaddr(true)?;
socket.set_reuseport(true)?; socket.set_reuseport(true)?;
@ -215,7 +228,11 @@ where
} }
Ok((socket, _)) => { Ok((socket, _)) => {
info!("Accepted new inbound connection from listener"); info!("Accepted new inbound connection from listener");
task::spawn(handle_from_listener(socket, handle_message.clone(), listener_map_for_task.clone())); task::spawn(handle_from_listener(
socket,
handle_message.clone(),
listener_map_for_task.clone(),
));
} }
} }
} }

View File

@ -1,44 +1,50 @@
use serde::Deserialize; #![cfg_attr(test, allow(unused))]
use std::fs;
use std::error::Error;
use log::{info, error, LevelFilter};
use simple_logger::SimpleLogger;
use tokio::signal::unix::{signal, SignalKind};
use db::DBPool; use db::DBPool;
use log::{info, LevelFilter};
use serde::Deserialize;
use simple_logger::SimpleLogger;
use std::error::Error;
use std::fs;
use tokio::signal::unix::{signal, SignalKind};
#[cfg(feature = "yamldump")]
use static_content::dumper::dump_static_content;
mod db; mod db;
mod language;
mod listener; mod listener;
mod message_handler; mod message_handler;
mod version_cutover;
mod av;
mod regular_tasks;
mod models; mod models;
mod static_content; mod regular_tasks;
mod language;
mod services; mod services;
mod static_content;
mod version_cutover;
pub type DResult<T> = Result<T, Box<dyn Error + Send + Sync>>; pub type DResult<T> = Result<T, Box<dyn Error + Send + Sync + 'static>>;
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
struct Config { struct Config {
listener: String, listener: String,
pidfile: String, pidfile: String,
database_conn_string: String database_conn_string: String,
} }
fn read_latest_config() -> DResult<Config> { fn read_latest_config() -> DResult<Config> {
serde_yaml::from_str(&fs::read_to_string("gameserver.conf")?). serde_yaml::from_str(&fs::read_to_string("gameserver.conf")?)
map_err(|error| Box::new(error) as Box<dyn Error + Send + Sync>) .map_err(|error| Box::new(error) as Box<dyn Error + Send + Sync>)
} }
#[tokio::main] #[tokio::main(worker_threads = 2)]
#[cfg(not(test))]
async fn main() -> DResult<()> { async fn main() -> DResult<()> {
SimpleLogger::new().with_level(LevelFilter::Info).init().unwrap(); SimpleLogger::new()
.with_level(LevelFilter::Info)
.init()
.unwrap();
#[cfg(feature = "yamldump")]
dump_static_content()?;
av::check().or_else(|e| -> Result<(), Box<dyn Error + Send + Sync>> {
error!("Couldn't verify age-verification.yml - this is not a complete game. Check README.md: {}", e);
Err(e)
})?;
let config = read_latest_config()?; let config = read_latest_config()?;
let pool = DBPool::start(&config.database_conn_string)?; let pool = DBPool::start(&config.database_conn_string)?;
@ -50,11 +56,12 @@ async fn main() -> DResult<()> {
let listener_map = listener::make_listener_map(); let listener_map = listener::make_listener_map();
let mh_pool = pool.clone(); let mh_pool = pool.clone();
listener::start_listener(config.listener, listener_map.clone(), listener::start_listener(
move |listener_id, msg| { config.listener,
message_handler::handle(listener_id, msg, mh_pool.clone()) listener_map.clone(),
} move |listener_id, msg| message_handler::handle(listener_id, msg, mh_pool.clone()),
).await?; )
.await?;
static_content::refresh_static_content(&pool).await?; static_content::refresh_static_content(&pool).await?;

View File

@ -1,28 +1,40 @@
use blastmud_interfaces::*;
use crate::db; use crate::db;
use MessageFromListener::*;
use uuid::Uuid;
use crate::DResult; use crate::DResult;
use blastmud_interfaces::*;
use uuid::Uuid;
use MessageFromListener::*;
mod new_session; mod new_session;
pub mod user_commands; pub mod user_commands;
#[derive(Clone,Debug)] #[derive(Clone, Debug)]
pub struct ListenerSession { pub struct ListenerSession {
pub listener: Uuid, pub listener: Uuid,
pub session: Uuid pub session: Uuid,
} }
pub async fn handle(listener: Uuid, msg: MessageFromListener, pool: db::DBPool) #[cfg(test)]
-> DResult<()> { impl Default for ListenerSession {
fn default() -> ListenerSession {
use uuid::uuid;
ListenerSession {
listener: uuid!("6f9c9b61-9228-4427-abd7-c4aef127a862"),
session: uuid!("668efb68-79d3-4004-9d6a-1e5757792e1a"),
}
}
}
pub async fn handle(listener: Uuid, msg: MessageFromListener, pool: db::DBPool) -> DResult<()> {
match msg { match msg {
ListenerPing { .. } => { pool.record_listener_ping(listener).await?; } ListenerPing { .. } => {
pool.record_listener_ping(listener).await?;
}
SessionConnected { session, source } => { SessionConnected { session, source } => {
new_session::handle( new_session::handle(&ListenerSession { listener, session }, source, &pool).await?;
&ListenerSession { listener, session }, source, &pool).await?;
} }
SessionDisconnected { session } => { SessionDisconnected { session } => {
pool.end_session(ListenerSession { listener, session }).await?; pool.end_session(ListenerSession { listener, session })
.await?;
} }
SessionSentLine { session, msg } => { SessionSentLine { session, msg } => {
user_commands::handle(&ListenerSession { listener, session }, &msg, &pool).await?; user_commands::handle(&ListenerSession { listener, session }, &msg, &pool).await?;

View File

@ -1,21 +1,61 @@
use crate::message_handler::ListenerSession;
use crate::DResult;
use crate::db::DBPool; use crate::db::DBPool;
use crate::message_handler::ListenerSession;
use crate::models::session::Session;
use crate::DResult;
use ansi::ansi; use ansi::ansi;
use std::default::Default; use std::default::Default;
use crate::models::session::Session;
// ANSI art version of the symbol we are legally required to display per:
// https://www.legislation.gov.au/Details/F2017C00102
// https://dom111.github.io/image-to-ansi/ can help convert it.
const AUS_RATING_SYMBOL: &'static str = "\x1b[38;5;235;48;5;234m▄\x1b[38;5;234;48;5;234m▄\x1b[38;5;235;48;5;235m▄\x1b[38;5;235;48;5;234m▄\x1b[38;5;234;48;5;234m▄▄▄\x1b[38;5;234;48;5;235m▄▄\x1b[38;5;234;48;5;234m▄\x1b[38;5;234;48;5;235m▄\x1b[38;5;234;48;5;234m▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄\x1b[38;5;234;48;5;235m▄\x1b[38;5;234;48;5;234m▄▄\x1b[38;5;234;48;5;235m▄\x1b[38;5;234;48;5;234m▄▄\x1b[38;5;235;48;5;234m▄\x1b[38;5;234;48;5;234m▄\x1b[38;5;234;48;5;235m▄\x1b[38;5;234;48;5;234m▄\x1b[38;5;235;48;5;235m▄\x1b[m
\x1b[38;5;233;48;5;234m\x1b[38;5;235;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;234m\x1b[38;5;234;48;5;234m\x1b[38;5;234;48;5;235m\x1b[38;5;233;48;5;235m\x1b[38;5;235;48;5;234m\x1b[38;5;236;48;5;234m\x1b[38;5;235;48;5;234m\x1b[38;5;236;48;5;234m\x1b[38;5;235;48;5;234m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;234m\x1b[38;5;234;48;5;233m\x1b[38;5;235;48;5;234m\x1b[38;5;234;48;5;234m\x1b[38;5;234;48;5;235m\x1b[38;5;234;48;5;234m\x1b[m
\x1b[38;5;234;48;5;234m\x1b[38;5;234;48;5;235m\x1b[38;5;236;48;5;233m\x1b[38;5;145;48;5;236m\x1b[38;5;230;48;5;245m\x1b[38;5;187;48;5;250m\x1b[38;5;186;48;5;188m\x1b[38;5;186;48;5;253m\x1b[38;5;186;48;5;254m\x1b[38;5;186;48;5;253m\x1b[38;5;187;48;5;251m\x1b[38;5;230;48;5;247m\x1b[38;5;188;48;5;239m\x1b[38;5;240;48;5;234m\x1b[38;5;234;48;5;235m\x1b[38;5;235;48;5;234m\x1b[38;5;234;48;5;234m\x1b[m
\x1b[38;5;235;48;5;234m\x1b[38;5;234;48;5;234m\x1b[38;5;234;48;5;235m\x1b[38;5;235;48;5;233m\x1b[38;5;250;48;5;245m\x1b[38;5;187;48;5;230m\x1b[38;5;185;48;5;143m\x1b[38;5;220;48;5;185m\x1b[38;5;11;48;5;185m\x1b[38;5;11;48;5;221m\x1b[38;5;11;48;5;185m\x1b[38;5;11;48;5;184m\x1b[38;5;11;48;5;220m\x1b[38;5;11;48;5;185m\x1b[38;5;11;48;5;221m\x1b[38;5;11;48;5;185m\x1b[38;5;184;48;5;143m\x1b[38;5;185;48;5;229m\x1b[38;5;224;48;5;252m\x1b[38;5;240;48;5;234m\x1b[38;5;235;48;5;234m\x1b[38;5;235;48;5;235m\x1b[38;5;235;48;5;234m\x1b[m
\x1b[38;5;234;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;235m\x1b[38;5;254;48;5;253m\x1b[38;5;186;48;5;186m\x1b[38;5;221;48;5;185m\x1b[38;5;11;48;5;11m\x1b[38;5;220;48;5;11m\x1b[38;5;11;48;5;11m\x1b[38;5;220;48;5;11m\x1b[38;5;11;48;5;11m\x1b[38;5;220;48;5;11m\x1b[38;5;227;48;5;11m\x1b[38;5;185;48;5;179m\x1b[38;5;255;48;5;230m\x1b[38;5;243;48;5;243m\x1b[38;5;233;48;5;234m\x1b[38;5;234;48;5;234m\x1b[38;5;234;48;5;235m\x1b[m
\x1b[38;5;234;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;235m\x1b[38;5;253;48;5;254m\x1b[38;5;186;48;5;186m\x1b[38;5;184;48;5;185m\x1b[38;5;11;48;5;11m\x1b[38;5;220;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;220;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;185;48;5;185m\x1b[38;5;230;48;5;230m\x1b[38;5;243;48;5;243m\x1b[38;5;233;48;5;233m\x1b[38;5;234;48;5;234m\x1b[m
\x1b[38;5;235;48;5;234m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;235m\x1b[38;5;253;48;5;253m\x1b[38;5;186;48;5;186m\x1b[38;5;11;48;5;184m\x1b[38;5;11;48;5;11m\x1b[38;5;220;48;5;11m\x1b[38;5;11;48;5;220m\x1b[38;5;184;48;5;11m\x1b[38;5;184;48;5;220m\x1b[38;5;184;48;5;11m\x1b[38;5;178;48;5;11m\x1b[38;5;184;48;5;11m\x1b[38;5;184;48;5;220m\x1b[38;5;184;48;5;11m\x1b[38;5;185;48;5;11m\x1b[38;5;184;48;5;227m\x1b[38;5;11;48;5;11m\x1b[38;5;185;48;5;185m\x1b[38;5;230;48;5;230m\x1b[38;5;242;48;5;242m\x1b[38;5;233;48;5;233m\x1b[38;5;234;48;5;234m\x1b[m
\x1b[38;5;235;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;235m\x1b[38;5;253;48;5;253m\x1b[38;5;186;48;5;186m\x1b[38;5;184;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;227;48;5;11m\x1b[38;5;3;48;5;58m\x1b[38;5;142;48;5;58m\x1b[38;5;185;48;5;94m\x1b[38;5;184;48;5;58m\x1b[38;5;184;48;5;3m\x1b[38;5;184;48;5;94m\x1b[38;5;185;48;5;58m\x1b[38;5;184;48;5;58m\x1b[38;5;184;48;5;94m\x1b[38;5;184;48;5;58m\x1b[38;5;184;48;5;94m\x1b[38;5;185;48;5;94m\x1b[38;5;184;48;5;94m\x1b[38;5;184;48;5;58m\x1b[38;5;58;48;5;58m\x1b[38;5;184;48;5;184m\x1b[38;5;11;48;5;11m\x1b[38;5;185;48;5;185m\x1b[48;5;230m \x1b[48;5;242m \x1b[48;5;233m \x1b[48;5;234m \x1b[m
\x1b[38;5;235;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;235m\x1b[38;5;253;48;5;253m\x1b[38;5;186;48;5;186m\x1b[38;5;221;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;220;48;5;11m\x1b[38;5;227;48;5;227m\x1b[38;5;94;48;5;58m\x1b[38;5;178;48;5;142m\x1b[38;5;227;48;5;220m\x1b[38;5;178;48;5;11m\x1b[38;5;100;48;5;11m\x1b[38;5;3;48;5;11m\x1b[38;5;3;48;5;227m\x1b[38;5;3;48;5;221m\x1b[38;5;3;48;5;11m\x1b[38;5;142;48;5;11m\x1b[38;5;185;48;5;11m\x1b[38;5;227;48;5;11m\x1b[38;5;11;48;5;11m\x1b[38;5;185;48;5;11m\x1b[38;5;142;48;5;11m\x1b[38;5;3;48;5;11m\x1b[38;5;3;48;5;227m\x1b[38;5;142;48;5;227m\x1b[38;5;184;48;5;11m\x1b[38;5;11;48;5;11m\x1b[38;5;220;48;5;220m\x1b[38;5;58;48;5;58m\x1b[38;5;184;48;5;178m\x1b[38;5;11;48;5;11m\x1b[38;5;185;48;5;185m\x1b[48;5;230m \x1b[48;5;242m \x1b[38;5;233;48;5;233m\x1b[38;5;234;48;5;234m\x1b[m
\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;235m\x1b[38;5;253;48;5;253m\x1b[38;5;186;48;5;186m\x1b[38;5;220;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;227;48;5;227m\x1b[38;5;94;48;5;58m\x1b[38;5;142;48;5;142m\x1b[38;5;227;48;5;227m\x1b[38;5;142;48;5;142m\x1b[38;5;234;48;5;235m\x1b[38;5;233;48;5;235m\x1b[38;5;235;48;5;234m\x1b[38;5;237;48;5;233m\x1b[38;5;58;48;5;233m\x1b[38;5;58;48;5;234m\x1b[38;5;234;48;5;235m\x1b[38;5;233;48;5;58m\x1b[38;5;236;48;5;143m\x1b[38;5;185;48;5;11m\x1b[38;5;58;48;5;184m\x1b[38;5;234;48;5;58m\x1b[38;5;233;48;5;236m\x1b[38;5;236;48;5;234m\x1b[38;5;58;48;5;233m\x1b[38;5;237;48;5;233m\x1b[38;5;234;48;5;236m\x1b[38;5;234;48;5;58m\x1b[38;5;58;48;5;184m\x1b[38;5;185;48;5;11m\x1b[38;5;11;48;5;11m\x1b[38;5;184;48;5;184m\x1b[38;5;58;48;5;58m\x1b[38;5;184;48;5;184m\x1b[48;5;11m \x1b[38;5;11;48;5;11m\x1b[38;5;185;48;5;185m\x1b[48;5;230m \x1b[48;5;242m \x1b[48;5;233m \x1b[48;5;234m \x1b[m
\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;235m\x1b[38;5;253;48;5;253m\x1b[38;5;186;48;5;186m\x1b[38;5;220;48;5;220m\x1b[48;5;11m \x1b[38;5;227;48;5;227m\x1b[48;5;3m \x1b[38;5;142;48;5;142m\x1b[38;5;11;48;5;227m\x1b[38;5;142;48;5;142m\x1b[38;5;235;48;5;235m\x1b[38;5;233;48;5;233m\x1b[38;5;142;48;5;101m\x1b[38;5;11;48;5;185m\x1b[38;5;11;48;5;184m\x1b[38;5;143;48;5;58m\x1b[38;5;235;48;5;234m\x1b[38;5;234;48;5;234m\x1b[38;5;3;48;5;142m\x1b[38;5;100;48;5;143m\x1b[38;5;234;48;5;234m\x1b[38;5;137;48;5;58m\x1b[38;5;227;48;5;185m\x1b[38;5;11;48;5;191m\x1b[38;5;11;48;5;185m\x1b[38;5;184;48;5;58m\x1b[38;5;58;48;5;234m\x1b[38;5;58;48;5;235m\x1b[38;5;136;48;5;143m\x1b[38;5;11;48;5;11m\x1b[38;5;184;48;5;184m\x1b[38;5;58;48;5;58m\x1b[38;5;184;48;5;184m\x1b[38;5;11;48;5;11m\x1b[38;5;185;48;5;185m\x1b[38;5;230;48;5;230m\x1b[38;5;242;48;5;242m\x1b[38;5;233;48;5;233m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;234m\x1b[m
\x1b[38;5;235;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;235m\x1b[38;5;253;48;5;253m\x1b[38;5;186;48;5;186m\x1b[38;5;220;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;227;48;5;227m\x1b[38;5;94;48;5;3m\x1b[38;5;142;48;5;142m\x1b[38;5;11;48;5;227m\x1b[38;5;142;48;5;142m\x1b[38;5;235;48;5;235m\x1b[38;5;234;48;5;233m\x1b[38;5;59;48;5;136m\x1b[38;5;137;48;5;227m\x1b[38;5;137;48;5;11m\x1b[38;5;3;48;5;227m\x1b[38;5;237;48;5;143m\x1b[38;5;234;48;5;234m\x1b[38;5;234;48;5;0m\x1b[38;5;143;48;5;136m\x1b[38;5;3;48;5;101m\x1b[38;5;234;48;5;232m\x1b[38;5;235;48;5;233m\x1b[38;5;142;48;5;143m\x1b[38;5;11;48;5;220m\x1b[38;5;184;48;5;11m\x1b[38;5;100;48;5;11m\x1b[38;5;3;48;5;227m\x1b[38;5;101;48;5;221m\x1b[38;5;100;48;5;228m\x1b[38;5;142;48;5;227m\x1b[38;5;11;48;5;11m\x1b[38;5;184;48;5;184m\x1b[38;5;58;48;5;58m\x1b[38;5;184;48;5;184m\x1b[48;5;11m \x1b[38;5;11;48;5;11m\x1b[38;5;185;48;5;185m\x1b[38;5;230;48;5;230m\x1b[38;5;242;48;5;242m\x1b[48;5;233m \x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;235m\x1b[m
\x1b[38;5;235;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;235m\x1b[38;5;253;48;5;253m\x1b[38;5;186;48;5;186m\x1b[38;5;220;48;5;221m\x1b[38;5;11;48;5;11m\x1b[38;5;227;48;5;227m\x1b[48;5;94m \x1b[38;5;143;48;5;142m\x1b[38;5;191;48;5;227m\x1b[38;5;143;48;5;142m\x1b[38;5;236;48;5;235m\x1b[38;5;234;48;5;233m\x1b[38;5;235;48;5;234m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;234m\x1b[38;5;58;48;5;233m\x1b[38;5;142;48;5;235m\x1b[38;5;11;48;5;3m\x1b[38;5;11;48;5;185m\x1b[38;5;3;48;5;64m\x1b[38;5;233;48;5;233m\x1b[38;5;235;48;5;235m\x1b[38;5;142;48;5;142m\x1b[38;5;11;48;5;11m\x1b[38;5;143;48;5;100m\x1b[38;5;58;48;5;234m\x1b[38;5;233;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;58;48;5;3m\x1b[38;5;11;48;5;11m\x1b[38;5;184;48;5;184m\x1b[38;5;58;48;5;58m\x1b[38;5;184;48;5;184m\x1b[38;5;11;48;5;11m\x1b[38;5;185;48;5;185m\x1b[38;5;230;48;5;230m\x1b[38;5;242;48;5;242m\x1b[48;5;233m \x1b[38;5;234;48;5;234m\x1b[m
\x1b[38;5;235;48;5;235m\x1b[38;5;234;48;5;234m\x1b[48;5;235m \x1b[48;5;253m \x1b[38;5;186;48;5;186m\x1b[38;5;220;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;3;48;5;3m\x1b[38;5;142;48;5;142m\x1b[38;5;11;48;5;227m\x1b[38;5;142;48;5;142m\x1b[38;5;234;48;5;234m\x1b[38;5;233;48;5;233m\x1b[38;5;137;48;5;100m\x1b[38;5;11;48;5;185m\x1b[38;5;11;48;5;184m\x1b[38;5;11;48;5;221m\x1b[38;5;220;48;5;11m\x1b[38;5;11;48;5;11m\x1b[38;5;11;48;5;220m\x1b[38;5;136;48;5;100m\x1b[38;5;234;48;5;234m\x1b[38;5;233;48;5;234m\x1b[38;5;3;48;5;142m\x1b[38;5;227;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;227;48;5;227m\x1b[38;5;142;48;5;221m\x1b[38;5;234;48;5;235m\x1b[38;5;234;48;5;233m\x1b[38;5;3;48;5;94m\x1b[38;5;11;48;5;220m\x1b[38;5;184;48;5;220m\x1b[38;5;58;48;5;58m\x1b[38;5;184;48;5;184m\x1b[38;5;11;48;5;11m\x1b[38;5;185;48;5;185m\x1b[38;5;230;48;5;230m\x1b[38;5;242;48;5;242m\x1b[38;5;233;48;5;233m\x1b[38;5;234;48;5;234m\x1b[m
\x1b[38;5;235;48;5;235m\x1b[38;5;234;48;5;234m\x1b[48;5;235m \x1b[38;5;253;48;5;253m\x1b[38;5;186;48;5;186m\x1b[38;5;220;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;3;48;5;3m\x1b[38;5;142;48;5;142m\x1b[38;5;11;48;5;11m\x1b[38;5;142;48;5;142m\x1b[38;5;235;48;5;234m\x1b[38;5;233;48;5;232m\x1b[38;5;137;48;5;137m\x1b[38;5;227;48;5;227m\x1b[38;5;11;48;5;11m\x1b[38;5;11;48;5;220m\x1b[38;5;220;48;5;11m\x1b[38;5;11;48;5;185m\x1b[38;5;100;48;5;235m\x1b[38;5;236;48;5;233m\x1b[38;5;233;48;5;235m\x1b[38;5;234;48;5;100m\x1b[38;5;235;48;5;143m\x1b[38;5;233;48;5;106m\x1b[38;5;234;48;5;236m\x1b[38;5;235;48;5;234m\x1b[38;5;3;48;5;237m\x1b[38;5;184;48;5;184m\x1b[38;5;11;48;5;11m\x1b[38;5;184;48;5;184m\x1b[38;5;58;48;5;58m\x1b[38;5;184;48;5;184m\x1b[38;5;11;48;5;11m\x1b[48;5;185m \x1b[38;5;230;48;5;230m\x1b[48;5;242m \x1b[48;5;233m \x1b[48;5;234m \x1b[38;5;235;48;5;235m\x1b[m
\x1b[38;5;234;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;235m\x1b[48;5;253m \x1b[38;5;186;48;5;186m\x1b[38;5;221;48;5;184m\x1b[38;5;11;48;5;11m\x1b[38;5;220;48;5;11m\x1b[38;5;221;48;5;227m\x1b[38;5;3;48;5;94m\x1b[38;5;185;48;5;142m\x1b[38;5;11;48;5;11m\x1b[38;5;220;48;5;142m\x1b[38;5;178;48;5;58m\x1b[38;5;185;48;5;236m\x1b[38;5;221;48;5;142m\x1b[38;5;11;48;5;11m\x1b[38;5;11;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;11;48;5;142m\x1b[38;5;185;48;5;58m\x1b[38;5;179;48;5;236m\x1b[38;5;179;48;5;235m\x1b[38;5;185;48;5;236m\x1b[38;5;184;48;5;58m\x1b[38;5;227;48;5;100m\x1b[38;5;11;48;5;185m\x1b[38;5;11;48;5;11m\x1b[38;5;220;48;5;11m\x1b[38;5;221;48;5;185m\x1b[38;5;58;48;5;58m\x1b[38;5;184;48;5;184m\x1b[38;5;11;48;5;11m\x1b[38;5;220;48;5;11m\x1b[38;5;11;48;5;11m\x1b[38;5;185;48;5;185m\x1b[38;5;230;48;5;230m\x1b[38;5;242;48;5;242m\x1b[48;5;233m \x1b[38;5;234;48;5;234m\x1b[38;5;234;48;5;235m\x1b[m
\x1b[38;5;235;48;5;234m\x1b[38;5;234;48;5;234m\x1b[48;5;235m \x1b[38;5;253;48;5;253m\x1b[38;5;186;48;5;186m\x1b[38;5;184;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;220;48;5;221m\x1b[38;5;58;48;5;58m\x1b[38;5;3;48;5;142m\x1b[38;5;100;48;5;227m\x1b[38;5;100;48;5;11m\x1b[38;5;136;48;5;11m\x1b[38;5;136;48;5;220m\x1b[38;5;100;48;5;11m\x1b[38;5;136;48;5;11m\x1b[38;5;100;48;5;11m\x1b[38;5;100;48;5;227m\x1b[38;5;100;48;5;11m\x1b[38;5;136;48;5;11m\x1b[38;5;100;48;5;11m\x1b[38;5;100;48;5;185m\x1b[38;5;58;48;5;58m\x1b[38;5;178;48;5;178m\x1b[38;5;11;48;5;11m\x1b[38;5;11;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;179;48;5;185m\x1b[38;5;230;48;5;230m\x1b[38;5;242;48;5;242m\x1b[38;5;233;48;5;233m\x1b[38;5;234;48;5;234m\x1b[m
\x1b[38;5;235;48;5;235m\x1b[38;5;234;48;5;234m\x1b[48;5;235m \x1b[38;5;253;48;5;253m\x1b[38;5;186;48;5;186m\x1b[38;5;184;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;11;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;11;48;5;142m\x1b[38;5;11;48;5;100m\x1b[38;5;11;48;5;136m\x1b[38;5;11;48;5;100m\x1b[38;5;227;48;5;100m\x1b[38;5;11;48;5;100m\x1b[38;5;11;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;185;48;5;185m\x1b[48;5;230m \x1b[38;5;242;48;5;242m\x1b[38;5;233;48;5;233m\x1b[38;5;234;48;5;234m\x1b[48;5;234m \x1b[m
\x1b[38;5;234;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;235m\x1b[38;5;253;48;5;253m\x1b[38;5;186;48;5;186m\x1b[38;5;220;48;5;11m\x1b[38;5;11;48;5;11m\x1b[38;5;220;48;5;11m\x1b[38;5;11;48;5;11m\x1b[48;5;11m \x1b[38;5;11;48;5;11m\x1b[38;5;185;48;5;185m\x1b[38;5;230;48;5;230m\x1b[38;5;242;48;5;242m\x1b[38;5;234;48;5;233m\x1b[38;5;234;48;5;234m\x1b[m
\x1b[38;5;235;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;234;48;5;235m\x1b[38;5;254;48;5;254m\x1b[38;5;186;48;5;186m\x1b[38;5;221;48;5;185m\x1b[38;5;11;48;5;11m\x1b[38;5;11;48;5;220m\x1b[38;5;11;48;5;11m\x1b[38;5;227;48;5;11m\x1b[38;5;179;48;5;185m\x1b[38;5;255;48;5;229m\x1b[38;5;242;48;5;243m\x1b[38;5;233;48;5;234m\x1b[38;5;234;48;5;234m\x1b[m
\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;235m\x1b[38;5;247;48;5;252m\x1b[38;5;230;48;5;187m\x1b[38;5;143;48;5;185m\x1b[38;5;185;48;5;11m\x1b[38;5;227;48;5;11m\x1b[38;5;11;48;5;11m\x1b[38;5;227;48;5;11m\x1b[38;5;185;48;5;11m\x1b[38;5;185;48;5;185m\x1b[38;5;186;48;5;143m\x1b[38;5;252;48;5;230m\x1b[38;5;237;48;5;241m\x1b[38;5;234;48;5;233m\x1b[38;5;235;48;5;235m\x1b[38;5;234;48;5;234m\x1b[m
\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;233m\x1b[38;5;234;48;5;239m\x1b[38;5;59;48;5;188m\x1b[38;5;251;48;5;229m\x1b[38;5;254;48;5;185m\x1b[38;5;230;48;5;179m\x1b[38;5;224;48;5;185m\x1b[38;5;230;48;5;185m\x1b[38;5;255;48;5;185m\x1b[38;5;230;48;5;185m\x1b[38;5;229;48;5;185m\x1b[38;5;255;48;5;179m\x1b[38;5;230;48;5;143m\x1b[38;5;252;48;5;186m\x1b[38;5;8;48;5;230m\x1b[38;5;235;48;5;8m\x1b[38;5;233;48;5;233m\x1b[38;5;234;48;5;234m\x1b[m
\x1b[38;5;234;48;5;234m\x1b[38;5;234;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;234;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;233m\x1b[38;5;234;48;5;235m\x1b[38;5;234;48;5;240m\x1b[38;5;234;48;5;243m\x1b[38;5;234;48;5;242m\x1b[38;5;233;48;5;242m\x1b[38;5;234;48;5;242m\x1b[38;5;234;48;5;241m\x1b[38;5;234;48;5;242m\x1b[38;5;233;48;5;241m\x1b[38;5;234;48;5;237m\x1b[38;5;235;48;5;233m\x1b[38;5;235;48;5;234m\x1b[38;5;234;48;5;235m\x1b[38;5;234;48;5;234m\x1b[m
\x1b[38;5;235;48;5;235m\x1b[38;5;235;48;5;234m\x1b[38;5;234;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;234m\x1b[38;5;235;48;5;235m\x1b[38;5;234;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;234;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;234;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;233m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;235m\x1b[38;5;234;48;5;234m\x1b[38;5;235;48;5;234m\x1b[38;5;235;48;5;235m\x1b[m
";
pub async fn handle(session: &ListenerSession, source: String, pool: &DBPool) -> DResult<()> { pub async fn handle(session: &ListenerSession, source: String, pool: &DBPool) -> DResult<()> {
pool.start_session(session, &Session { source, ..Default::default() }).await?; pool.start_session(
pool.queue_for_session(&session, Some(&ansi!("\ session,
&Session {
source,
..Default::default()
},
)
.await?;
pool.queue_for_session(&session, Some(&(ansi!("\
Welcome to <red>BlastMud<reset> - a text-based post-apocalyptic \ Welcome to <red>BlastMud<reset> - a text-based post-apocalyptic \
game <bold>restricted to adults (18+)<reset>\r\n\ game\r\n").to_owned() + AUS_RATING_SYMBOL + ansi!("\r\n\
Parental guidance recommended. Violence. Online interactivity.\r\n\
\r\n\
Some commands to get you started:\r\n\ Some commands to get you started:\r\n\
\t<bold>register <lt>username> <lt>password> <lt>email><reset> to register as a new user.\r\n\ \t<bold>register <lt>username> <lt>password> <lt>email><reset> to register as a new user.\r\n\
\t<bold>login <lt>username> <lt>password><reset> to log in as an existing user.\r\n\ \t<bold>login <lt>username> <lt>password><reset> to log in as an existing user.\r\n\
\t<bold>help<reset> to learn more.\r\n\ \t<bold>help<reset> to learn more.\r\n\
[Please note BlastMud is still under development. You are welcome to play as we \ [Please contact staff@blastmud.org with any feedback or suggestions on how to \r\n\
develop it, but note it might still have bugs, unimplemented features, and \ improve Blastmud, to report any inappropriate user generated content or behaviour, or if you \r\n\
unbalanced gameplay aspects].\r\n"))).await?; need any other help from the game's operators; use <bold>report abuse<reset> immediately after \r\n\
receiving any inappropriate message to store evidence].\r\n\
Blastmud's privacy policy: https://blastmud.org/privacy/\r\n")))).await?;
Ok(()) Ok(())
} }

View File

@ -1,52 +1,118 @@
use super::ListenerSession; use super::ListenerSession;
#[cfg(not(test))]
use crate::db::is_concurrency_error;
#[double]
use crate::db::DBTrans;
use crate::db::{DBPool, ItemSearchParams};
use crate::models::user::UserFlag;
use crate::models::{item::Item, session::Session, user::User};
use crate::DResult; use crate::DResult;
use crate::db::{DBTrans, DBPool, ItemSearchParams}; #[cfg(not(test))]
use ansi::ansi; use ansi::ansi;
use phf::phf_map;
use async_trait::async_trait; use async_trait::async_trait;
use crate::models::{session::Session, user::User, item::Item}; #[cfg(not(test))]
use log::warn; use log::warn;
use mockall_double::double;
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
mod agree; mod agree;
pub mod attack; mod allow;
mod attack;
mod butcher;
pub mod buy;
mod c;
pub mod close;
pub mod corp;
pub mod cut;
pub mod delete;
mod describe; mod describe;
pub mod drink;
pub mod drop;
pub mod eat;
mod feint;
pub mod fill;
mod fire;
pub mod follow;
mod gear;
pub mod get;
mod hack;
mod help; mod help;
pub mod hire;
mod ignore; mod ignore;
mod less_explicit_mode; pub mod improvise;
mod install;
mod inventory;
mod invincible;
mod list;
pub mod load;
mod login; mod login;
mod look; mod look;
pub mod make;
mod map; mod map;
pub mod movement; pub mod movement;
pub mod open;
mod page;
pub mod parsing; pub mod parsing;
pub mod pay;
pub mod plug;
mod pow;
pub mod put;
mod quit; mod quit;
mod register; pub mod recline;
pub mod register;
pub mod remove;
pub mod rent;
mod report;
mod reset_spawns;
pub mod say; pub mod say;
mod scan;
pub mod scavenge;
mod score;
mod sell;
mod share;
mod sign;
pub mod sit;
mod staff_show;
pub mod stand;
mod status;
mod stop;
mod turn;
mod uninstall;
pub mod use_cmd;
mod vacate;
pub mod wear;
mod whisper; mod whisper;
mod who;
pub mod wield;
mod write;
pub struct VerbContext<'l> { pub struct VerbContext<'l> {
pub session: &'l ListenerSession, pub session: &'l ListenerSession,
pub session_dat: &'l mut Session, pub session_dat: &'l mut Session,
pub user_dat: &'l mut Option<User>, pub user_dat: &'l mut Option<User>,
pub trans: &'l DBTrans pub trans: &'l DBTrans,
} }
pub enum CommandHandlingError { pub enum CommandHandlingError {
UserError(String), UserError(String),
SystemError(Box<dyn std::error::Error + Send + Sync>) SystemError(Box<dyn std::error::Error + Send + Sync>),
} }
use CommandHandlingError::*; pub use CommandHandlingError::*;
#[async_trait] #[async_trait]
pub trait UserVerb { pub trait UserVerb {
async fn handle(self: &Self, ctx: &mut VerbContext, verb: &str, remaining: &str) -> UResult<()>; async fn handle(self: &Self, ctx: &mut VerbContext, verb: &str, remaining: &str)
-> UResult<()>;
} }
pub type UResult<A> = Result<A, CommandHandlingError>; pub type UResult<A> = Result<A, CommandHandlingError>;
impl<T> From<T> for CommandHandlingError
impl<T> From<T> for CommandHandlingError where T: Into<Box<dyn std::error::Error + Send + Sync>> { where
T: Into<Box<dyn std::error::Error + Send + Sync>>,
{
fn from(input: T) -> CommandHandlingError { fn from(input: T) -> CommandHandlingError {
SystemError(input.into()) SystemError(input.into())
} }
@ -56,76 +122,198 @@ pub fn user_error<A>(msg: String) -> UResult<A> {
Err(UserError(msg)) Err(UserError(msg))
} }
/* Verb registries list types of commands available in different circumstances. */ /* Verb registries list types of commands available in different circumstances. */
pub type UserVerbRef = &'static (dyn UserVerb + Sync + Send); pub type UserVerbRef = &'static (dyn UserVerb + Sync + Send);
type UserVerbRegistry = phf::Map<&'static str, UserVerbRef>;
static ALWAYS_AVAILABLE_COMMANDS: UserVerbRegistry = phf_map! { pub fn always_available_commands() -> &'static BTreeMap<&'static str, UserVerbRef> {
"" => ignore::VERB, static V: OnceCell<BTreeMap<&'static str, UserVerbRef>> = OnceCell::new();
"help" => help::VERB, V.get_or_init(|| {
"quit" => quit::VERB, vec![
}; ("", ignore::VERB),
("help", help::VERB),
("quit", quit::VERB),
]
.into_iter()
.collect()
})
}
static UNREGISTERED_COMMANDS: UserVerbRegistry = phf_map! { pub fn unregistered_commands() -> &'static BTreeMap<&'static str, UserVerbRef> {
"agree" => agree::VERB, static V: OnceCell<BTreeMap<&'static str, UserVerbRef>> = OnceCell::new();
"connect" => login::VERB, V.get_or_init(|| {
"less_explicit_mode" => less_explicit_mode::VERB, vec![
"login" => login::VERB, ("agree", agree::VERB),
"register" => register::VERB, ("connect", login::VERB),
}; ("login", login::VERB),
("register", register::VERB),
]
.into_iter()
.collect()
})
}
static REGISTERED_COMMANDS: UserVerbRegistry = phf_map! { pub fn registered_commands() -> &'static BTreeMap<&'static str, UserVerbRef> {
// Movement comments first: static V: OnceCell<BTreeMap<&'static str, UserVerbRef>> = OnceCell::new();
"north" => movement::VERB, V.get_or_init(|| {
"n" => movement::VERB, vec![
"northeast" => movement::VERB, // Movement comments first:
"ne" => movement::VERB, ("north", movement::VERB),
"east" => movement::VERB, ("n", movement::VERB),
"e" => movement::VERB, ("northeast", movement::VERB),
"southeast" => movement::VERB, ("ne", movement::VERB),
"se" => movement::VERB, ("east", movement::VERB),
"south" => movement::VERB, ("e", movement::VERB),
"s" => movement::VERB, ("southeast", movement::VERB),
"southwest" => movement::VERB, ("se", movement::VERB),
"sw" => movement::VERB, ("south", movement::VERB),
"west" => movement::VERB, ("s", movement::VERB),
"w" => movement::VERB, ("southwest", movement::VERB),
"northwest" => movement::VERB, ("sw", movement::VERB),
"nw" => movement::VERB, ("west", movement::VERB),
"up" => movement::VERB, ("w", movement::VERB),
"down" => movement::VERB, ("northwest", movement::VERB),
("nw", movement::VERB),
("up", movement::VERB),
("down", movement::VERB),
("in", movement::VERB),
// Other commands (alphabetical except aliases grouped):
("allow", allow::VERB),
("disallow", allow::VERB),
("attack", attack::VERB),
("butcher", butcher::VERB),
("buy", buy::VERB),
("c", c::VERB),
("close", close::VERB),
("corp", corp::VERB),
("cut", cut::VERB),
("delete", delete::VERB),
("drink", drink::VERB),
("drop", drop::VERB),
("eat", eat::VERB),
("fill", fill::VERB),
("fire", fire::VERB),
("follow", follow::VERB),
("unfollow", follow::VERB),
("gear", gear::VERB),
("get", get::VERB),
("hack", hack::VERB),
("hire", hire::VERB),
("improv", improvise::VERB),
("improvise", improvise::VERB),
("improvize", improvise::VERB),
("install", install::VERB),
("inventory", inventory::VERB),
("inv", inventory::VERB),
("i", inventory::VERB),
("kill", attack::VERB),
("k", attack::VERB),
("describe", describe::VERB),
("l", look::VERB),
("look", look::VERB),
("read", look::VERB),
("examine", look::VERB),
("ex", look::VERB),
("feint", feint::VERB),
("list", list::VERB),
("load", load::VERB),
("unload", load::VERB),
("lm", map::VERB),
("lmap", map::VERB),
("gm", map::VERB),
("gmap", map::VERB),
("make", make::VERB),
("open", open::VERB),
("p", page::VERB),
("page", page::VERB),
("pg", page::VERB),
("rep", page::VERB),
("repl", page::VERB),
("reply", page::VERB),
("pay", pay::VERB),
("plug", plug::VERB),
("pow", pow::VERB),
("power", pow::VERB),
("powerattack", pow::VERB),
("put", put::VERB),
("recline", recline::VERB),
("remove", remove::VERB),
("rent", rent::VERB),
("report", report::VERB),
(("\'", say::VERB)),
("say", say::VERB),
("scan", scan::VERB),
("scavenge", scavenge::VERB),
("search", scavenge::VERB),
("sc", score::VERB),
("score", score::VERB),
("sell", sell::VERB),
("share", share::VERB),
("serious", share::VERB),
("amicable", share::VERB),
("joking", share::VERB),
("parody", share::VERB),
("play", share::VERB),
("thoughts", share::VERB),
("exploring", share::VERB),
("roaming", share::VERB),
("fishing", share::VERB),
("good", share::VERB),
("surviving", share::VERB),
("slow", share::VERB),
("normal", share::VERB),
("intense", share::VERB),
("sign", sign::VERB),
("sit", sit::VERB),
("stand", stand::VERB),
("st", status::VERB),
("stat", status::VERB),
("stats", status::VERB),
("status", status::VERB),
("stop", stop::VERB),
("turn", turn::VERB),
("uninstall", uninstall::VERB),
("use", use_cmd::VERB),
("vacate", vacate::VERB),
("-", whisper::VERB),
("whisper", whisper::VERB),
("tell", whisper::VERB),
("wear", wear::VERB),
("wield", wield::VERB),
("who", who::VERB),
("write", write::VERB),
]
.into_iter()
.collect()
})
}
// Other commands (alphabetical except aliases grouped): pub fn staff_commands() -> &'static BTreeMap<&'static str, UserVerbRef> {
"attack" => attack::VERB, static V: OnceCell<BTreeMap<&'static str, UserVerbRef>> = OnceCell::new();
"kill" => attack::VERB, V.get_or_init(|| {
"k" => attack::VERB, vec![
("staff_invincible", invincible::VERB),
"describe" => describe::VERB, ("staff_reset_spawns", reset_spawns::VERB),
"l" => look::VERB, ("staff_show", staff_show::VERB),
"look" => look::VERB, ]
"read" => look::VERB, .into_iter()
.collect()
"lmap" => map::VERB, })
}
"\'" => say::VERB,
"say" => say::VERB,
"-" => whisper::VERB,
"whisper" => whisper::VERB,
"tell" => whisper::VERB,
};
fn resolve_handler(ctx: &VerbContext, cmd: &str) -> Option<&'static UserVerbRef> { fn resolve_handler(ctx: &VerbContext, cmd: &str) -> Option<&'static UserVerbRef> {
let mut result = ALWAYS_AVAILABLE_COMMANDS.get(cmd); let mut result = always_available_commands().get(cmd);
match &ctx.user_dat { match &ctx.user_dat {
None => { None => {
result = result.or_else(|| UNREGISTERED_COMMANDS.get(cmd)); result = result.or_else(|| unregistered_commands().get(cmd));
} }
Some(user_dat) => { Some(user_dat) => {
if user_dat.terms.terms_complete { if user_dat.terms.terms_complete {
result = result.or_else(|| REGISTERED_COMMANDS.get(cmd)); result = result.or_else(|| registered_commands().get(cmd));
if user_dat.user_flags.contains(&UserFlag::Staff) {
result = result.or_else(|| staff_commands().get(cmd));
}
} else if cmd == "agree" { } else if cmd == "agree" {
result = Some(&agree::VERB); result = Some(&agree::VERB);
} }
@ -135,83 +323,247 @@ fn resolve_handler(ctx: &VerbContext, cmd: &str) -> Option<&'static UserVerbRef>
result result
} }
pub async fn handle(session: &ListenerSession, msg: &str, pool: &DBPool) -> DResult<()> { #[cfg(not(test))]
pub async fn handle_in_trans(
session: &ListenerSession,
msg: &str,
pool: &DBPool,
trans: DBTrans,
) -> DResult<()> {
let (cmd, params) = parsing::parse_command_name(msg); let (cmd, params) = parsing::parse_command_name(msg);
let trans = pool.start_transaction().await?;
let (mut session_dat, mut user_dat) = match trans.get_session_user_model(session).await? { let (mut session_dat, mut user_dat) = match trans.get_session_user_model(session).await? {
None => { None => {
// If the session has been cleaned up from the database, there is // If the session has been cleaned up from the database, there is
// nowhere to go from here, so just ignore it. // nowhere to go from here, so just ignore it.
warn!("Got command from session not in database: {}", session.session); warn!(
"Got command from session not in database: {}",
session.session
);
return Ok(()); return Ok(());
} }
Some(v) => v Some(v) => v,
}; };
let mut ctx = VerbContext { session, trans: &trans, session_dat: &mut session_dat, let mut ctx = VerbContext {
user_dat: &mut user_dat }; session,
trans: &trans,
session_dat: &mut session_dat,
user_dat: &mut user_dat,
};
let handler_opt = resolve_handler(&ctx, cmd); let handler_opt = resolve_handler(&ctx, cmd);
match handler_opt { match handler_opt {
None => { None => {
trans.queue_for_session(session, trans
Some(ansi!( .queue_for_session(
"That's not a command I know. Try <bold>help<reset>\r\n" session,
)) Some(ansi!(
).await?; "That's not a command I know. Try <bold>help<reset>\r\n"
)),
)
.await?;
trans.commit().await?; trans.commit().await?;
} }
Some(handler) => { Some(handler) => match handler.handle(&mut ctx, cmd, params).await {
match handler.handle(&mut ctx, cmd, params).await { Ok(()) => {
Ok(()) => { trans.commit().await?;
trans.commit().await?;
}
Err(UserError(err_msg)) => {
pool.queue_for_session(session, Some(&(err_msg + "\r\n"))).await?;
}
Err(SystemError(e)) => Err(e)?
} }
} Err(UserError(err_msg)) => {
pool.queue_for_session(session, Some(&(err_msg + "\r\n")))
.await?;
}
Err(SystemError(e)) => Err(e)?,
},
} }
Ok(()) Ok(())
} }
pub fn is_likely_explicit(msg: &str) -> bool { #[cfg(not(test))]
static EXPLICIT_MARKER_WORDS: OnceCell<Vec<&'static str>> = pub async fn handle(session: &ListenerSession, msg: &str, pool: &DBPool) -> DResult<()> {
OnceCell::new(); loop {
let markers = EXPLICIT_MARKER_WORDS.get_or_init(|| let trans = pool.start_transaction().await?;
vec!("fuck", "sex", "cock", "cunt", "dick", "pussy", "whore", match handle_in_trans(session, msg, pool, trans).await {
"orgasm", "erection", "nipple", "boob", "tit")); Ok(_) => break,
for word in markers { Err(e) => {
if msg.contains(word) { if is_concurrency_error(e.as_ref()) {
return true continue;
} else {
return Err(e);
}
}
} }
} }
false pool.bump_session_time(&session).await?;
Ok(())
}
#[cfg(test)]
pub async fn handle(_session: &ListenerSession, _msg: &str, _pool: &DBPool) -> DResult<()> {
unimplemented!();
}
pub fn is_likely_illegal(msg: &str) -> bool {
static ILLEGAL_MARKER_WORDS: OnceCell<Vec<&'static str>> = OnceCell::new();
let illegal_markers = ILLEGAL_MARKER_WORDS.get_or_init(|| {
vec![
"lolita",
"jailbait",
"abu sayyaf",
"al-qaida",
"al-qaida",
"al-shabaab",
"boko haram",
"hamas",
"tahrir al-sham",
"hizballah",
"hezbollah",
"hurras al-din",
"islamic state",
"jaish-e-mohammad",
"jamaat mujahideen",
"jamaat mujahideen",
"jamaat nusrat",
"jamaat nusrat",
"jemaah islamiyah",
"kurdistan workers",
"lashkar-e-tayyiba",
"likud",
"national socialist order",
"palestinian islamic jihad",
"sonnenkrieg",
"race war",
// For now we'll block all URLs - we could allow by domain perhaps?
"http:",
"https:",
"ftp:",
]
});
static MINOR_MARKER_WORDS: OnceCell<Vec<&'static str>> = OnceCell::new();
let minor_markers =
MINOR_MARKER_WORDS.get_or_init(|| vec!["young", "underage", "child", "teen", "minor"]);
static EXPLICIT_MARKER_WORDS: OnceCell<Vec<&'static str>> = OnceCell::new();
let explicit_markers = EXPLICIT_MARKER_WORDS.get_or_init(|| {
vec![
"fuck",
"sex",
"cock",
"cunt",
"dick",
"pussy",
"whore",
"orgasm",
"erection",
"nipple",
"boob",
"tit",
"xxx",
"nsfw",
"uncensored",
]
});
let msg_lower = msg.to_lowercase();
for word in illegal_markers {
if msg_lower.contains(word) {
return true;
}
}
let mut minor_word = false;
let mut explicit_word = false;
for word in minor_markers {
if msg_lower.contains(word) {
minor_word = true;
}
}
for word in explicit_markers {
if msg_lower.contains(word) {
explicit_word = true;
}
}
explicit_word && minor_word
} }
pub fn get_user_or_fail<'l>(ctx: &'l VerbContext) -> UResult<&'l User> { pub fn get_user_or_fail<'l>(ctx: &'l VerbContext) -> UResult<&'l User> {
ctx.user_dat.as_ref() ctx.user_dat
.as_ref()
.ok_or_else(|| UserError("Not logged in".to_owned())) .ok_or_else(|| UserError("Not logged in".to_owned()))
} }
pub fn get_user_or_fail_mut<'l>(ctx: &'l mut VerbContext) -> UResult<&'l mut User> { pub fn get_user_or_fail_mut<'l>(ctx: &'l mut VerbContext) -> UResult<&'l mut User> {
ctx.user_dat.as_mut() ctx.user_dat
.as_mut()
.ok_or_else(|| UserError("Not logged in".to_owned())) .ok_or_else(|| UserError("Not logged in".to_owned()))
} }
pub async fn get_player_item_or_fail(ctx: &VerbContext<'_>) -> UResult<Arc<Item>> { pub async fn get_player_item_or_fail(ctx: &VerbContext<'_>) -> UResult<Arc<Item>> {
Ok(ctx.trans.find_item_by_type_code( Ok(ctx
"player", &get_user_or_fail(ctx)?.username.to_lowercase()).await? .trans
.ok_or_else(|| UserError("Your character is gone, you'll need to re-register or ask an admin".to_owned()))?) .find_item_by_type_code("player", &get_user_or_fail(ctx)?.username.to_lowercase())
.await?
.ok_or_else(|| {
UserError(
"Your character is gone, you'll need to re-register or ask an admin".to_owned(),
)
})?)
} }
pub async fn search_item_for_user<'l>(ctx: &'l VerbContext<'l>, search: &'l ItemSearchParams<'l>) -> pub async fn search_item_for_user<'l>(
UResult<Arc<Item>> { ctx: &'l VerbContext<'l>,
Ok(match &ctx.trans.resolve_items_by_display_name_for_player(search).await?[..] { search: &'l ItemSearchParams<'l>,
[] => user_error("Sorry, I couldn't find anything matching.".to_owned())?, ) -> UResult<Arc<Item>> {
Ok(
match &ctx
.trans
.resolve_items_by_display_name_for_player(search)
.await?[..]
{
[] => user_error(format!(
"Sorry, I couldn't find anything matching \"{}\".",
search.query
))?,
[match_it] => match_it.clone(), [match_it] => match_it.clone(),
[item1, ..] => [item1, ..] => item1.clone(),
item1.clone(), },
}) )
}
pub async fn search_items_for_user<'l>(
ctx: &'l VerbContext<'l>,
search: &'l ItemSearchParams<'l>,
) -> UResult<Vec<Arc<Item>>> {
Ok(
match &ctx
.trans
.resolve_items_by_display_name_for_player(search)
.await?[..]
{
[] => user_error(format!(
"Sorry, I couldn't find anything matching \"{}\".",
search.query
))?,
v => v.into_iter().map(|it| it.clone()).collect(),
},
)
}
#[cfg(test)]
mod test {
use crate::db::MockDBTrans;
#[test]
fn resolve_handler_finds_unregistered() {
use super::*;
let trans = MockDBTrans::new();
let sess: ListenerSession = Default::default();
let mut user_dat: Option<User> = None;
let mut session_dat: Session = Default::default();
let ctx = VerbContext {
session: &sess,
trans: &trans,
session_dat: &mut session_dat,
user_dat: &mut user_dat,
};
assert_eq!(resolve_handler(&ctx, "agree").is_some(), true);
}
} }

View File

@ -1,14 +1,15 @@
use super::{VerbContext, UserVerb, UserVerbRef, UResult, user_error}; use super::{user_error, UResult, UserVerb, UserVerbRef, VerbContext};
use crate::models::user::{User, UserTermData}; use crate::models::user::{User, UserTermData};
use async_trait::async_trait;
use ansi::ansi; use ansi::ansi;
use async_trait::async_trait;
use base64::Engine;
use chrono::Utc; use chrono::Utc;
pub struct Verb; pub struct Verb;
static REQUIRED_AGREEMENTS: [&str;4] = [ static REQUIRED_AGREEMENTS: [&str;4] = [
"I acknowledge that BlastMud is for adults only, and certify that I am over 18 years of age \ "I acknowledge that I am over 12 years of age, and that if I am under 16 years of age, I have permission \
(or any higher relevant age of majority in my country) and want to view this content.", from a parent or legal guardian to play the game.",
"THIS GAME IS PROVIDED BY THE CREATORS, STAFF, VOLUNTEERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR \ "THIS GAME IS PROVIDED BY THE CREATORS, STAFF, VOLUNTEERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR \
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND \ IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND \
FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE CREATORS, STAFF, VOLUNTEERS OR \ FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE CREATORS, STAFF, VOLUNTEERS OR \
@ -24,7 +25,11 @@ static REQUIRED_AGREEMENTS: [&str;4] = [
is personally identifying information, or is objectionable or abhorrent (including, without \ is personally identifying information, or is objectionable or abhorrent (including, without \
limitation, any content related to sexual violence, real or fictional children under 18, bestiality, \ limitation, any content related to sexual violence, real or fictional children under 18, bestiality, \
the promotion or glorification of proscribed drug use, or fetishes that involve degrading or \ the promotion or glorification of proscribed drug use, or fetishes that involve degrading or \
inflicting pain on someone for the enjoyment of others). I agree to defend, indemnify, and hold \ inflicting pain on someone for the enjoyment of others), or which promotes terrorism. \
I acknowledge that the game and services provided in connection with it are open to players under \
18 years of age, and agree that I will not send any sexual or age-restricted content to any player \
I have not verified is over 18, nor attempt to groom any such player for sexual activity at a later time. \
I agree to defend, indemnify, and hold \
harmless the creators, staff, volunteers and contributors in any matter relating to content sent \ harmless the creators, staff, volunteers and contributors in any matter relating to content sent \
(or re-sent) by me, in any matter arising from the game sending content to me, and in any matter \ (or re-sent) by me, in any matter arising from the game sending content to me, and in any matter \
consequential to sharing my password, using an insecure password, or otherwise allowing or taking \ consequential to sharing my password, using an insecure password, or otherwise allowing or taking \
@ -40,33 +45,38 @@ static REQUIRED_AGREEMENTS: [&str;4] = [
fn user_mut<'a>(ctx: &'a mut VerbContext) -> UResult<&'a mut User> { fn user_mut<'a>(ctx: &'a mut VerbContext) -> UResult<&'a mut User> {
match ctx.user_dat.as_mut() { match ctx.user_dat.as_mut() {
None => Err("Checked agreements before user logged in, which is a logic error")?, None => Err("Checked agreements before user logged in, which is a logic error")?,
Some(user_dat) => Ok(user_dat) Some(user_dat) => Ok(user_dat),
} }
} }
fn terms<'a>(ctx: &'a VerbContext<'a>) -> UResult<&'a UserTermData> { fn terms<'a>(ctx: &'a VerbContext<'a>) -> UResult<&'a UserTermData> {
match ctx.user_dat.as_ref() { match ctx.user_dat.as_ref() {
None => Err("Checked agreements before user logged in, which is a logic error")?, None => Err("Checked agreements before user logged in, which is a logic error")?,
Some(user_dat) => Ok(&user_dat.terms) Some(user_dat) => Ok(&user_dat.terms),
} }
} }
fn first_outstanding_agreement(ctx: &VerbContext) -> UResult<Option<(String, String)>> { fn first_outstanding_agreement(ctx: &VerbContext) -> UResult<Option<(String, String)>> {
let existing_terms = &terms(ctx)?.accepted_terms; let existing_terms = &terms(ctx)?.accepted_terms;
for agreement in REQUIRED_AGREEMENTS { for agreement in REQUIRED_AGREEMENTS {
let shortcode = let shortcode = base64::engine::general_purpose::STANDARD_NO_PAD.encode(
base64::encode(ring::digest::digest(&ring::digest::SHA256, ring::digest::digest(&ring::digest::SHA256, agreement.as_bytes()),
agreement.as_bytes()))[0..20].to_owned(); )[0..20]
.to_owned();
match existing_terms.get(&shortcode) { match existing_terms.get(&shortcode) {
None => { return Ok(Some((agreement.to_owned(), shortcode))); } None => {
return Ok(Some((agreement.to_owned(), shortcode)));
}
Some(_) => {} Some(_) => {}
} }
} }
Ok(None) Ok(None)
} }
pub async fn check_and_notify_accepts<'a, 'b>(ctx: &'a mut VerbContext<'b>) -> UResult<bool> where 'b: 'a { pub async fn check_and_notify_accepts<'a, 'b>(ctx: &'a mut VerbContext<'b>) -> UResult<bool>
where
'b: 'a,
{
match first_outstanding_agreement(ctx)? { match first_outstanding_agreement(ctx)? {
None => { None => {
let user = user_mut(ctx)?; let user = user_mut(ctx)?;
@ -78,12 +88,20 @@ pub async fn check_and_notify_accepts<'a, 'b>(ctx: &'a mut VerbContext<'b>) -> U
let user = user_mut(ctx)?; let user = user_mut(ctx)?;
user.terms.terms_complete = false; user.terms.terms_complete = false;
user.terms.last_presented_term = Some(hash); user.terms.last_presented_term = Some(hash);
ctx.trans.queue_for_session(ctx.session, Some(&format!(ansi!( ctx.trans
"Please review the following:\r\n\ .queue_for_session(
ctx.session,
Some(&format!(
ansi!(
"Please review the following:\r\n\
\t{}\r\n\ \t{}\r\n\
Type <green><bold>agree<reset> to accept. If you can't or don't agree, you \ Type <green><bold>agree<reset> to accept. If you can't or don't agree, you \
unfortunately can't play, so type <red><bold>quit<reset> to log off.\r\n"), unfortunately can't play, so type <red><bold>quit<reset> to log off.\r\n"
text))).await?; ),
text
)),
)
.await?;
Ok(false) Ok(false)
} }
} }
@ -91,24 +109,37 @@ pub async fn check_and_notify_accepts<'a, 'b>(ctx: &'a mut VerbContext<'b>) -> U
#[async_trait] #[async_trait]
impl UserVerb for Verb { impl UserVerb for Verb {
async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, _remaining: &str) -> UResult<()> { async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
let user = user_mut(ctx)?; let user = user_mut(ctx)?;
match user.terms.last_presented_term.as_ref() { match user.terms.last_presented_term.as_ref() {
None => { None => {
drop(user);
user_error("There was nothing pending your agreement.".to_owned())?; user_error("There was nothing pending your agreement.".to_owned())?;
} }
Some(last_term) => { Some(last_term) => {
user.terms.accepted_terms.insert(last_term.to_owned(), Utc::now()); user.terms
drop(user); .accepted_terms
.insert(last_term.to_owned(), Utc::now());
if check_and_notify_accepts(ctx).await? { if check_and_notify_accepts(ctx).await? {
ctx.trans.queue_for_session(ctx.session, Some( ctx.trans
ansi!("That was the last of the terms to agree to - welcome onboard!\r\n\ .queue_for_session(
Hint: Try <bold>l<reset> to look around.\r\n"))).await?; ctx.session,
Some(ansi!(
"That was the last of the terms to agree to - welcome onboard!\r\n\
Hint: Try <bold>l<reset> to look around.\r\n"
)),
)
.await?;
} }
} }
} }
ctx.trans.save_user_model(ctx.user_dat.as_ref().unwrap()).await?; ctx.trans
.save_user_model(ctx.user_dat.as_ref().unwrap())
.await?;
Ok(()) Ok(())
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,113 +1,89 @@
use super::{VerbContext, UserVerb, UserVerbRef, UResult, user_error, use super::{
get_player_item_or_fail, search_item_for_user}; get_player_item_or_fail, search_item_for_user, user_error, UResult, UserVerb, UserVerbRef,
use async_trait::async_trait; VerbContext,
};
use crate::{
db::ItemSearchParams,
models::{consent::ConsentType, effect::EffectType, item::ItemFlag},
services::{check_consent, combat::start_attack},
};
use ansi::ansi; use ansi::ansi;
use crate::services::broadcast_to_room; use async_trait::async_trait;
use crate::db::{DBTrans, ItemSearchParams};
use crate::models::{item::{Item, LocationActionType, Subattack}};
use async_recursion::async_recursion;
pub async fn stop_attacking(trans: &DBTrans, by_whom: &Item, to_whom: &Item) -> UResult<()> {
let mut new_to_whom = (*to_whom).clone();
if let Some(ac) = new_to_whom.active_combat.as_mut() {
let old_attacker = format!("{}/{}", by_whom.item_type, by_whom.item_code);
ac.attacked_by.retain(|v| v != &old_attacker);
trans.save_item_model(&new_to_whom).await?;
}
let mut new_by_whom = (*by_whom).clone();
if let Some(ac) = new_by_whom.active_combat.as_mut() {
ac.attacking = None;
}
new_by_whom.action_type = LocationActionType::Normal;
trans.save_item_model(&new_by_whom).await?;
Ok(())
}
#[async_recursion]
pub async fn start_attack(trans: &DBTrans, by_whom: &Item, to_whom: &Item) -> UResult<()> {
let mut msg_exp = String::new();
let mut msg_nonexp = String::new();
let mut verb: String = "attacks".to_string();
match by_whom.action_type {
LocationActionType::Sitting | LocationActionType::Reclining => {
msg_exp.push_str(&format!(ansi!("{} stands up.\n"), &by_whom.display));
msg_nonexp.push_str(&format!(ansi!("{} stands up.\n"),
by_whom.display_less_explicit.as_ref().unwrap_or(&by_whom.display)));
},
LocationActionType::Attacking(_) => {
match by_whom.active_combat.as_ref().and_then(|ac| ac.attacking.as_ref().and_then(|s| s.split_once("/"))) {
Some((cur_type, cur_code)) if cur_type == to_whom.item_type && cur_code == to_whom.item_code =>
user_error(format!("You're already attacking {}!", to_whom.pronouns.object))?,
Some((cur_type, cur_code)) => {
if let Some(cur_item_arc) = trans.find_item_by_type_code(cur_type, cur_code).await? {
stop_attacking(trans, by_whom, &cur_item_arc).await?;
}
}
_ => {}
}
verb = "refocuses ".to_string() + &by_whom.pronouns.possessive + " attacks on";
},
_ => {}
}
msg_exp.push_str(&format!(
ansi!("<red>{} {} {}.<reset>\n"),
&by_whom.display_for_sentence(true, 1, true),
verb,
&to_whom.display_for_sentence(true, 1, false))
);
msg_nonexp.push_str(&format!(
ansi!("<red>{} {} {}.<reset>\n"),
&by_whom.display_for_sentence(false, 1, true),
verb,
&to_whom.display_for_sentence(false, 1, false))
);
broadcast_to_room(trans, &by_whom.location, None, &msg_exp, Some(msg_nonexp.as_str())).await?;
let mut by_whom_for_update = by_whom.clone();
by_whom_for_update.active_combat.get_or_insert_with(|| Default::default()).attacking =
Some(format!("{}/{}",
&to_whom.item_type, &to_whom.item_code));
by_whom_for_update.action_type = LocationActionType::Attacking(Subattack::Normal);
let mut to_whom_for_update = to_whom.clone();
to_whom_for_update.active_combat.get_or_insert_with(|| Default::default()).attacked_by.push(
format!("{}/{}",
&by_whom.item_type, &by_whom.item_code)
);
trans.save_item_model(&by_whom_for_update).await?;
trans.save_item_model(&to_whom_for_update).await?;
// Auto-counterattack if victim isn't busy.
if to_whom_for_update.active_combat.as_ref().and_then(|ac| ac.attacking.as_ref()) == None {
start_attack(trans, &to_whom_for_update, &by_whom_for_update).await?;
}
Ok(())
}
pub struct Verb; pub struct Verb;
#[async_trait] #[async_trait]
impl UserVerb for Verb { impl UserVerb for Verb {
async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?; let player_item = get_player_item_or_fail(ctx).await?;
let attack_whom = search_item_for_user(ctx, &ItemSearchParams {
include_loc_contents: true, if player_item.death_data.is_some() {
..ItemSearchParams::base(&player_item, remaining) user_error("It doesn't really seem fair, but you realise you won't be able to attack anyone while you're dead!".to_string())?;
}).await?; }
if player_item
.active_effects
.iter()
.any(|v| v.0 == EffectType::Stunned)
{
user_error("You're too stunned to attack.".to_owned())?;
}
let attack_whom = search_item_for_user(
ctx,
&ItemSearchParams {
include_loc_contents: true,
limit: 1,
..ItemSearchParams::base(&player_item, remaining)
},
)
.await?;
let (loctype, loccode) = match player_item.location.split_once("/") {
None => user_error("Your current location is invalid!".to_owned())?,
Some(l) => l,
};
let player_loc = match ctx.trans.find_item_by_type_code(loctype, loccode).await? {
None => user_error("Your current location is invalid!".to_owned())?,
Some(l) => l,
};
if player_loc.flags.contains(&ItemFlag::NoSeeContents) {
user_error("It is too foggy to even see who is here, let alone attack!".to_owned())?;
}
match attack_whom.item_type.as_str() { match attack_whom.item_type.as_str() {
"npc" => {} "npc" => {}
"player" => {}, "player" => {}
_ => user_error("Only characters (players / NPCs) accept whispers".to_string())? _ => user_error("Only characters (players / NPCs) can be attacked".to_string())?,
} }
if attack_whom.item_code == player_item.item_code && attack_whom.item_type == player_item.item_type { if attack_whom.item_code == player_item.item_code
&& attack_whom.item_type == player_item.item_type
{
user_error("That's you, silly!".to_string())? user_error("That's you, silly!".to_string())?
} }
if attack_whom.is_challenge_attack_only { if !check_consent(
// Add challenge check here. ctx.trans,
user_error(ansi!("<blue>Your wristpad vibrates and blocks you from doing that.<reset> You get a feeling that while the empire might be gone, the system to stop subjects with working wristpads from fighting each unless they have an accepted challenge very much functional. [Try <bold>help challenge<reset>]").to_string())? "attack",
&ConsentType::Fight,
&player_item,
&attack_whom,
)
.await?
{
user_error(ansi!("<blue>Your wristpad vibrates and blocks you from doing that.<reset> You get a feeling that while the empire might be gone, the system to stop subjects with working wristpads from fighting each unless they have an consented is very much functional. [Try <bold>help allow<reset>]").to_string())?
}
if attack_whom.death_data.is_some() {
user_error("There's no point attacking the dead!".to_string())?
}
if attack_whom.active_climb.is_some() {
user_error("They are already climbing away!".to_string())?
} }
start_attack(&ctx.trans, &player_item, &attack_whom).await start_attack(&ctx.trans, &player_item, &attack_whom).await

View File

@ -0,0 +1,91 @@
use super::{
cut::ensure_has_butcher_tool, get_player_item_or_fail, search_item_for_user, user_error,
UResult, UserError, UserVerb, UserVerbRef, VerbContext,
};
use crate::{
db::ItemSearchParams,
models::item::DeathData,
regular_tasks::queued_command::{queue_command, QueueCommand},
services::combat::corpsify_item,
static_content::possession_type::possession_data,
};
use async_trait::async_trait;
use std::sync::Arc;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error(
"You butcher things while they are dead, not while YOU are dead!".to_owned(),
)?
}
let possible_corpse = search_item_for_user(
ctx,
&ItemSearchParams {
include_loc_contents: true,
dead_first: true,
..ItemSearchParams::base(&player_item, remaining.trim())
},
)
.await?;
let possession_types = match possible_corpse.death_data.as_ref() {
None => user_error(format!(
"You can't do that while {} is still alive!",
possible_corpse.pronouns.subject
))?,
Some(DeathData {
parts_remaining, ..
}) => parts_remaining,
}
.clone();
let corpse = if possible_corpse.item_type == "corpse" {
possible_corpse
} else if possible_corpse.item_type == "npc" || possible_corpse.item_type == "player" {
let mut possible_corpse_mut = (*possible_corpse).clone();
possible_corpse_mut.location = if possible_corpse.item_type == "npc" {
"room/valhalla"
} else {
"room/repro_xv_respawn"
}
.to_owned();
Arc::new(corpsify_item(&ctx.trans, &possible_corpse).await?)
} else {
user_error("You can't butcher that!".to_owned())?
};
ensure_has_butcher_tool(&ctx.trans, &player_item).await?;
let mut player_item_mut = (*player_item).clone();
for possession_type in possession_types {
let possession_data = possession_data()
.get(&possession_type)
.ok_or_else(|| UserError("That part doesn't exist anymore".to_owned()))?;
queue_command(
ctx,
&mut player_item_mut,
&QueueCommand::Cut {
from_corpse: corpse.item_code.clone(),
what_part: possession_data.display.to_owned(),
},
)
.await?;
}
ctx.trans.save_item_model(&player_item_mut).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,191 @@
use super::{
get_player_item_or_fail, parsing::parse_offset, user_error, UResult, UserVerb, UserVerbRef,
VerbContext,
};
#[double]
use crate::db::DBTrans;
use crate::{
models::item::Item,
services::{
capacity::{check_item_capacity, check_item_ref_capacity, CapacityLevel},
urges::change_stress_considering_cool,
},
static_content::possession_type::possession_data,
static_content::room,
DResult,
};
use ansi::ansi;
use async_trait::async_trait;
use mockall_double::double;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error(
"Nobody seems to listen when you try to buy... possibly because you're dead."
.to_owned(),
)?
}
let (heretype, herecode) = player_item
.location
.split_once("/")
.unwrap_or(("room", "repro_xv_chargen"));
if heretype != "room" {
user_error("Can't buy anything because you're not in a shop.".to_owned())?;
}
let room = match room::room_map_by_code().get(herecode) {
None => user_error("Can't find that shop.".to_owned())?,
Some(r) => r,
};
if room.stock_list.is_empty() {
user_error("Can't buy anything because you're not in a shop.".to_owned())?
}
let (offset_m, remaining) = parse_offset(remaining);
let mut offset_remaining = offset_m.unwrap_or(1);
let match_item = remaining.trim().to_lowercase();
if match_item == "" {
user_error("You need to specify what to buy.".to_owned())?
}
for stock in &room.stock_list {
if !stock.can_buy {
continue;
}
if let Some(possession_type) = possession_data().get(&stock.possession_type) {
if possession_type
.display
.to_lowercase()
.starts_with(&match_item)
|| possession_type
.aliases
.iter()
.any(|al| al.starts_with(&match_item))
{
if offset_remaining <= 1 {
if let Some(user) = ctx.user_dat.as_mut() {
let mut paid_price = stock.list_price;
if user.credits < stock.list_price {
if stock.poverty_discount {
if let Some(urges) = player_item.urges.as_ref() {
if urges.stress.value > 500 {
user_error(ansi!("You don't have the money to pay full price, \
and are too tired to try begging for a \
discount. [Hint: Try to <bold>recline<reset> \
for a bit and try again]").to_owned())?;
}
let mut player_item_mut = (*player_item).clone();
change_stress_considering_cool(
&ctx.trans,
&mut player_item_mut,
2000,
)
.await?;
ctx.trans.save_item_model(&player_item_mut).await?;
}
paid_price = user.credits;
user.credits = 0;
} else {
user_error(
"You don't have enough credits to buy that!".to_owned(),
)?;
}
} else {
user.credits -= stock.list_price;
}
let player_item_str = player_item.refstr();
let item_code = ctx.trans.alloc_item_code().await?;
let loc = match check_item_capacity(
ctx.trans,
&player_item,
possession_type.weight,
)
.await?
{
CapacityLevel::OverBurdened | CapacityLevel::AboveItemLimit => {
match check_item_ref_capacity(
ctx.trans,
&player_item.location,
possession_type.weight,
)
.await?
{
CapacityLevel::AboveItemLimit => user_error(
"You can't carry it, and there is too much stuff \
here already"
.to_owned(),
)?,
_ => {
ctx.trans.queue_for_session(
&ctx.session,
Some(
"It's too much for you to carry so you leave it on the ground.\n")
).await?;
&player_item.location
}
}
}
_ => &player_item_str,
};
let new_item = Item {
item_code: format!("{}", item_code),
location: loc.to_owned(),
..stock.possession_type.clone().into()
};
ctx.trans.create_item(&new_item).await?;
create_item_default_contents(&ctx.trans, &new_item).await?;
ctx.trans
.queue_for_session(
&ctx.session,
Some(&format!(
"Your wristpad beeps for a deduction of {} credits.\n",
paid_price
)),
)
.await?;
}
return Ok(());
} else {
offset_remaining -= 1;
}
}
}
}
user_error(ansi!("That doesn't seem to be for sale. Try <bold>list<reset>").to_owned())
}
}
pub async fn create_item_default_contents(trans: &DBTrans, item: &Item) -> DResult<()> {
if let Some(container_data) = item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
.and_then(|pt| pt.container_data.as_ref())
{
for sub_possession_type in &container_data.default_contents {
let sub_item_code = trans.alloc_item_code().await?;
let new_sub_item = Item {
item_code: format!("{}", sub_item_code),
location: item.refstr(),
..sub_possession_type.clone().into()
};
trans.create_item(&new_sub_item).await?;
}
}
Ok(())
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,72 @@
use super::{
get_player_item_or_fail, get_user_or_fail, user_error, UResult, UserVerb, UserVerbRef,
VerbContext,
};
use crate::models::corp::CorpCommType;
use ansi::{ansi, ignore_special_characters};
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let user = get_user_or_fail(ctx)?;
let (corp_id, corp, msg) = if remaining.starts_with("@") {
match remaining[1..].split_once(" ") {
None => user_error(
"Usage: c message (lowest ordered corp) or c @corpname message".to_owned(),
)?,
Some((corpname, msg)) => {
let (corp_id, corp, _) = match ctx
.trans
.match_user_corp_by_name(corpname.trim(), &user.username)
.await?
{
None => {
user_error("You don't seem to belong to a matching corp!".to_owned())?
}
Some(c) => c,
};
(corp_id, corp, msg.trim())
}
}
} else {
let (corp_id, corp) = match ctx.trans.get_default_corp_for_user(&user.username).await? {
None => user_error("You're not a member of any corps.".to_owned())?,
Some(v) => v,
};
(corp_id, corp, remaining.trim())
};
let msg = ignore_special_characters(msg);
if msg.trim() == "" {
user_error("Message required.".to_owned())?;
}
let player = get_player_item_or_fail(ctx).await?;
ctx.trans
.broadcast_to_corp(
&corp_id,
&CorpCommType::Chat,
Some(&user.username),
&format!(
ansi!("<yellow>{} (to {}): <reset><bold>\"{}\"<reset>\n"),
&player.display_for_sentence(1, true),
&corp.name,
&msg
),
)
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,160 @@
use super::{
get_player_item_or_fail,
open::{is_door_in_direction, DoorSituation},
user_error, UResult, UserError, UserVerb, UserVerbRef, VerbContext,
};
use crate::{
models::item::DoorState,
regular_tasks::queued_command::{
queue_command_and_save, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::comms::broadcast_to_room,
static_content::room::Direction,
};
use async_trait::async_trait;
use std::time;
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
let direction = match ctx.command {
QueueCommand::CloseDoor { direction } => direction,
_ => user_error("Unexpected queued command".to_owned())?,
};
let use_location = if ctx.item.death_data.is_some() {
user_error("Your ethereal hands don't seem to be able to move the door.".to_owned())?
} else {
&ctx.item.location
};
match is_door_in_direction(&ctx.trans, &direction, use_location).await? {
DoorSituation::NoDoor => user_error("There is no door to close.".to_owned())?,
DoorSituation::DoorIntoRoom {
state: DoorState { open: false, .. },
..
}
| DoorSituation::DoorOutOfRoom {
state: DoorState { open: false, .. },
..
} => user_error("The door is already closed.".to_owned())?,
_ => {}
}
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
let direction = match ctx.command {
QueueCommand::CloseDoor { direction } => direction,
_ => user_error("Unexpected queued command".to_owned())?,
};
let use_location = if ctx.item.death_data.is_some() {
user_error("Your ethereal hands don't seem to be able to move the door.".to_owned())?
} else {
&ctx.item.location
};
let (room_1, dir_in_room, room_2) =
match is_door_in_direction(&ctx.trans, &direction, use_location).await? {
DoorSituation::NoDoor => user_error("There is no door to close.".to_owned())?,
DoorSituation::DoorIntoRoom {
state: DoorState { open: false, .. },
..
}
| DoorSituation::DoorOutOfRoom {
state: DoorState { open: false, .. },
..
} => user_error("The door is already closed.".to_owned())?,
DoorSituation::DoorIntoRoom {
room_with_door,
current_room,
..
} => {
if let Some(revdir) = direction.reverse() {
let mut entering_room_mut = (*room_with_door).clone();
if let Some(door_map) = entering_room_mut.door_states.as_mut() {
if let Some(door) = door_map.get_mut(&revdir) {
(*door).open = false;
}
};
ctx.trans.save_item_model(&entering_room_mut).await?;
(room_with_door, revdir, current_room)
} else {
user_error("There's no door possible there.".to_owned())?
}
}
DoorSituation::DoorOutOfRoom {
room_with_door,
new_room,
..
} => {
let mut entering_room_mut = (*room_with_door).clone();
if let Some(door_map) = entering_room_mut.door_states.as_mut() {
if let Some(door) = door_map.get_mut(&direction) {
(*door).open = false;
}
}
ctx.trans.save_item_model(&entering_room_mut).await?;
(room_with_door, direction.clone(), new_room)
}
};
for (loc, dir) in [
(&room_1.refstr(), &dir_in_room.describe()),
(
&room_2.refstr(),
&dir_in_room
.reverse()
.map(|d| d.describe())
.unwrap_or_else(|| "outside".to_owned()),
),
] {
broadcast_to_room(
&ctx.trans,
loc,
None,
&format!(
"{} closes the door to the {}.\n",
&ctx.item.display_for_sentence(1, true),
dir
),
)
.await?;
}
ctx.trans
.delete_task(
"SwingShut",
&format!("{}/{}", &room_1.refstr(), &dir_in_room.describe()),
)
.await?;
Ok(())
}
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let dir =
Direction::parse(remaining).ok_or_else(|| UserError("Unknown direction".to_owned()))?;
let player_item = get_player_item_or_fail(ctx).await?;
queue_command_and_save(
ctx,
&player_item,
&QueueCommand::CloseDoor {
direction: dir.clone(),
},
)
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,995 @@
use super::{
get_player_item_or_fail, get_user_or_fail, parsing::parse_command_name,
rent::recursively_destroy_or_move_item, search_item_for_user, user_error, UResult, UserVerb,
UserVerbRef, VerbContext,
};
use crate::{
db::ItemSearchParams,
language::caps_first,
models::corp::{CorpCommType, CorpMembership, CorpPermission},
};
use ansi::{ansi, ignore_special_characters};
use async_trait::async_trait;
use chrono::Utc;
use humantime;
use itertools::Itertools;
use std::collections::BTreeSet;
pub fn check_corp_perm(perm: &CorpPermission, mem: &CorpMembership) -> bool {
mem.permissions
.iter()
.any(|p| *p == CorpPermission::Holder || *p == *perm)
}
async fn corp_invite(ctx: &mut VerbContext<'_>, remaining: &str) -> UResult<()> {
let (target_raw, into_raw) = match remaining.rsplit_once(" into ") {
None => user_error(
ansi!("Usage: <bold>corp hire<reset> username <bold>into<reset> corpname").to_owned(),
)?,
Some(c) => c,
};
let user = get_user_or_fail(ctx)?;
let player = get_player_item_or_fail(ctx).await?;
let (corp_id, corp, mem) = match ctx
.trans
.match_user_corp_by_name(into_raw.trim(), &user.username)
.await?
{
None => user_error("You don't seem to belong to a matching corp!".to_owned())?,
Some(c) => c,
};
if !check_corp_perm(&CorpPermission::Hire, &mem) || mem.joined_at.is_none() {
user_error("You don't have hiring permissions for that corp".to_owned())?;
}
let target_user = search_item_for_user(
ctx,
&ItemSearchParams {
include_loc_contents: true,
..ItemSearchParams::base(&player, target_raw.trim())
},
)
.await?;
if target_user.item_type != "player" {
user_error("Only players can be hired.".to_owned())?;
}
let (their_sess, _their_sess_dat) = match ctx
.trans
.find_session_for_player(&target_user.item_code)
.await?
{
None => user_error("The user needs to be logged in while you hire them.".to_owned())?,
Some(c) => c,
};
if ctx.trans.list_corp_members(&corp_id).await?.len() > 40 {
user_error(
"Your corp seems a bit too big to manage already \
- fire someone first!"
.to_owned(),
)?;
}
ctx.trans.expire_old_invites().await?;
match ctx
.trans
.match_user_corp_by_name(into_raw.trim(), &target_user.item_code)
.await?
{
None => (),
Some((
_,
_,
CorpMembership {
invited_at: Some(_),
..
},
)) => user_error(format!(
"{}'s already been invited.",
&caps_first(&target_user.pronouns.subject)
))?,
Some((_, _, _)) => user_error(format!(
"{}'s already hired.",
&caps_first(&target_user.pronouns.subject)
))?,
};
let new_mem = CorpMembership {
invited_at: Some(Utc::now()),
allow_combat: corp.allow_combat_required,
permissions: corp.member_permissions.clone(),
..Default::default()
};
ctx.trans
.upsert_corp_membership(&corp_id, &target_user.item_code, &new_mem)
.await?;
ctx.trans.queue_for_session(&their_sess, Some(&format!(
ansi!("{} wants to hire you into {}! Type <bold>corp join {}<reset> to accept.{}\n"),
&player.display_for_sentence(1, true),
&corp.name,
&corp.name,
if new_mem.allow_combat {
" This corp is configured to allow the leadership to declare war on other corps; if you join, you may be attacked by members of other corps, even without your personal consent."
} else {
""
}
))).await?;
ctx.trans
.broadcast_to_corp(
&corp_id,
&CorpCommType::Notice,
None,
&format!(
"{} has invited {} to join {}!\n",
user.username,
&target_user.display_for_sentence(1, false),
corp.name
),
)
.await?;
Ok(())
}
async fn corp_join(ctx: &mut VerbContext<'_>, remaining: &str) -> UResult<()> {
let user = get_user_or_fail(ctx)?;
let (corpid, corp, mut mem) = match ctx
.trans
.match_user_corp_by_name(remaining, &user.username)
.await?
{
None => user_error("Corp not found".to_owned())?,
Some(v) => v,
};
if mem.joined_at.is_some() {
user_error("You have already joined that corp!".to_owned())?;
}
mem.joined_at = Some(Utc::now());
mem.invited_at = None;
ctx.trans
.upsert_corp_membership(&corpid, &user.username, &mem)
.await?;
ctx.trans
.broadcast_to_corp(
&corpid,
&CorpCommType::Notice,
None,
&format!(
ansi!("There is a loud <red>cheer<reset> as {} accepts an offer to join {}!\n"),
&user.username, &corp.name
),
)
.await?;
Ok(())
}
async fn corp_list(ctx: &mut VerbContext<'_>, _remaining: &str) -> UResult<()> {
let mut msg = String::new();
let user = get_user_or_fail(ctx)?;
let corps = ctx
.trans
.get_corp_memberships_for_user(&user.username)
.await?;
if corps.is_empty() {
msg.push_str(ansi!(
"You don't yet belong to any corps - try <bold>who<reset> to see the \
corps of people online, and message someone in a corp to see if they \
will hire you! Or buy a new corp licence at the Kings Office in Melbs.\n"
));
} else {
msg.push_str("You belong to the following corps:\n");
msg.push_str(&format!(
ansi!("<bgblue><white><bold>| {:20} | {:7} | {:5} |<reset>\n"),
"Name", "Combat?", "Order"
));
for corp in &corps {
match corp {
(
_,
name,
CorpMembership {
allow_combat: combat,
priority: p,
..
},
) => {
msg.push_str(&format!(
"| {:20} | {:7} | {:5} |\n",
name,
if *combat { "Y" } else { "N" },
p
));
}
}
}
}
ctx.trans.queue_for_session(ctx.session, Some(&msg)).await?;
Ok(())
}
async fn corp_leave(ctx: &mut VerbContext<'_>, remaining: &str) -> UResult<()> {
let user = get_user_or_fail(ctx)?;
let (corpid, corp, mem) = match ctx
.trans
.match_user_corp_by_name(remaining, &user.username)
.await?
{
None => user_error("Corp not found".to_owned())?,
Some(v) => v,
};
if mem.joined_at.is_some() {
ctx.trans
.broadcast_to_corp(
&corpid,
&CorpCommType::Notice,
None,
&format!(
ansi!("There is a loud <red>boo<reset> as {} resigns from {}!\n"),
user.username, corp.name
),
)
.await?;
} else {
ctx.trans
.queue_for_session(
ctx.session,
Some(&format!(
"You decline your invitation to join {}\n",
corp.name
)),
)
.await?;
}
let mut delete_corp = false;
if mem.permissions.contains(&CorpPermission::Holder) {
let username_l = user.username.to_lowercase();
let members = ctx.trans.list_corp_members(&corpid).await?;
if members.len() == 1 {
delete_corp = true;
} else if !members.iter().any(|(name, mem)| {
*name != username_l
&& mem.joined_at.is_some()
&& mem.permissions.contains(&CorpPermission::Holder)
}) {
user_error(
"The last holder cannot resign from a non-empty \
corp - fire everyone else first, or promote a \
successor to holder"
.to_owned(),
)?;
}
}
ctx.trans
.delete_corp_membership(&corpid, &user.username)
.await?;
if delete_corp {
for dynzone in ctx
.trans
.find_dynzone_for_owner(&format!("corp/{}", &corp.name))
.await?
{
recursively_destroy_or_move_item(&ctx.trans, &dynzone).await?;
}
ctx.trans.delete_corp(&corpid).await?;
}
Ok(())
}
async fn corp_fire(ctx: &mut VerbContext<'_>, remaining: &str) -> UResult<()> {
let (target_raw, into_raw) = match remaining.rsplit_once(" from ") {
None => user_error(
ansi!("Usage: <bold>corp fire<reset> username <bold>from<reset> corpname").to_owned(),
)?,
Some(c) => c,
};
let user = get_user_or_fail(ctx)?;
let player = get_player_item_or_fail(ctx).await?;
let (corp_id, corp, mem) = match ctx
.trans
.match_user_corp_by_name(into_raw.trim(), &user.username)
.await?
{
None => user_error("You don't seem to belong to a matching corp!".to_owned())?,
Some(c) => c,
};
if !check_corp_perm(&CorpPermission::Fire, &mem) || mem.joined_at.is_none() {
user_error("You don't have firing permissions for that corp".to_owned())?;
}
let target_user = search_item_for_user(
ctx,
&ItemSearchParams {
include_all_players: true,
..ItemSearchParams::base(&player, target_raw.trim())
},
)
.await?;
if target_user.item_type != "player" {
user_error("Only players can be fired.".to_owned())?;
}
if target_user.item_code == player.item_code {
user_error("Fire yourself? You know you can just resign right?".to_owned())?;
}
match ctx
.trans
.match_user_corp_by_name(into_raw.trim(), &target_user.item_code)
.await?
{
None => user_error(format!(
"{} isn't currently hired.",
&caps_first(&target_user.pronouns.subject)
))?,
Some((
_,
_,
CorpMembership {
permissions: their_perm,
joined_at: Some(their_join),
..
},
)) => {
if their_perm.contains(&CorpPermission::Holder) {
if !mem.permissions.contains(&CorpPermission::Holder) {
user_error(
"I love the ambition, but only holders can fire holders!".to_owned(),
)?;
}
if their_join < mem.joined_at.unwrap_or(Utc::now()) {
user_error(
"Whoah there young whippersnapper, holders can't fire more senior holders!"
.to_owned(),
)?;
}
}
}
_ => {}
}
ctx.trans
.broadcast_to_corp(
&corp_id,
&CorpCommType::Notice,
None,
&format!(
ansi!(
"A <blue>nervous silence<reset> falls across \
the workers as {} fires {} from {}.\n"
),
user.username,
target_user.display_for_sentence(1, false),
corp.name
),
)
.await?;
ctx.trans
.delete_corp_membership(&corp_id, &target_user.item_code)
.await?;
Ok(())
}
async fn corp_promote(ctx: &mut VerbContext<'_>, remaining: &str) -> UResult<()> {
let usage_error = || {
user_error(
ansi!("Usage: <bold>corp promote<reset> username <bold>in<reset> corpname <bold>to<reset> title <bold>privileges<reset> privileges\n\
Title can be any plain text up to 20 characters long.\n\
Permissions start with + or - (to add or take away) followed immediately by a permission name (e.g. holder, hire, fire, war)").to_owned()
)
};
let (remaining, permissions_raw) = match remaining.rsplit_once(" privileges ") {
None => usage_error()?,
Some(c) => c,
};
let (target_raw, remaining) = match remaining.split_once(" in ") {
None => usage_error()?,
Some(c) => c,
};
let (corpname_raw, title_raw) = match remaining.split_once(" to ") {
None => usage_error()?,
Some(c) => c,
};
let title = ignore_special_characters(title_raw.trim());
if title.len() > 20 {
user_error("New title must be 20 characters or less".to_owned())?;
}
let psplit = permissions_raw.split(" ");
let mut perm_add: BTreeSet<CorpPermission> = BTreeSet::new();
let mut perm_rem: BTreeSet<CorpPermission> = BTreeSet::new();
for perm in psplit {
let add = if perm.starts_with("+") {
true
} else if perm.starts_with("-") {
false
} else {
user_error(format!(
"Expected {} to start with + or -",
ignore_special_characters(perm)
))?
};
let perm = ignore_special_characters(&perm[1..]).to_lowercase();
let perm = match CorpPermission::parse(&perm) {
None => user_error(format!("Unknown permission {}", perm))?,
Some(v) => v,
};
(if add { &mut perm_add } else { &mut perm_rem }).insert(perm);
}
match perm_add.intersection(&perm_rem).next() {
Some(perm) => user_error(format!(
"You tried to both add and remove privilege {} - make up your mind!",
perm.display()
))?,
None => {}
}
let user = get_user_or_fail(ctx)?;
let player = get_player_item_or_fail(ctx).await?;
let (corp_id, corp, mem) = match ctx
.trans
.match_user_corp_by_name(corpname_raw.trim(), &user.username)
.await?
{
None => user_error("You don't seem to belong to a matching corp!".to_owned())?,
Some(c) => c,
};
if !check_corp_perm(&CorpPermission::Promote, &mem) || mem.joined_at.is_none() {
user_error("You don't have promote permissions for that corp".to_owned())?;
}
let target_user = search_item_for_user(
ctx,
&ItemSearchParams {
include_all_players: true,
..ItemSearchParams::base(&player, target_raw.trim())
},
)
.await?;
if target_user.item_type != "player" {
user_error("Only players can be promoted.".to_owned())?;
}
let mut their_mem = match ctx
.trans
.match_user_corp_by_name(corpname_raw.trim(), &target_user.item_code)
.await?
{
None => user_error(format!(
"{} isn't currently hired.",
&caps_first(&target_user.pronouns.subject)
))?,
Some((_, _, v)) => v,
};
match their_mem {
CorpMembership {
permissions: ref their_perm,
joined_at: Some(their_join),
..
} => {
if their_perm.contains(&CorpPermission::Holder) {
if !mem.permissions.contains(&CorpPermission::Holder) {
user_error(
"I love the ambition, but only holders can promote/demote holders!"
.to_owned(),
)?;
}
if their_join < mem.joined_at.unwrap_or(Utc::now()) {
user_error("Whoah there young whippersnapper, holders can't promote/demote more senior holders!".to_owned())?;
}
}
}
_ => {}
}
if target_user.item_code == player.item_code {
if !mem.permissions.contains(&CorpPermission::Holder) {
user_error("Only holders can promote / demote themselves".to_owned())?
} else if perm_rem.contains(&CorpPermission::Holder) {
let members = ctx.trans.list_corp_members(&corp_id).await?;
if !members.iter().any(|(name, mem)| {
*name != player.item_code
&& mem.joined_at.is_some()
&& mem.permissions.contains(&CorpPermission::Holder)
}) {
user_error(
"The last holder cannot demote themselves - \
promote a successor to holder first"
.to_owned(),
)?
}
}
}
if !mem.permissions.contains(&CorpPermission::Holder)
&& !(&perm_add | &perm_rem).is_subset(&mem.permissions.clone().into_iter().collect())
{
user_error("You can only change permissions you have yourself.".to_owned())?
}
let perm_str_raw = perm_add
.iter()
.map(|v| "+".to_owned() + v.display())
.join(" ")
+ " "
+ &perm_rem
.iter()
.map(|v| "-".to_owned() + v.display())
.join(" ");
ctx.trans.broadcast_to_corp(
&corp_id,
&CorpCommType::Notice, None,
&format!("Everyone looks up from their desk as {} changes {}'s job title in {} to {} ({}).\n",
user.username,
target_user.display_for_sentence(1, false),
corp.name, &title, &perm_str_raw.trim())).await?;
their_mem.job_title = title;
their_mem.permissions = (&(&their_mem
.permissions
.clone()
.into_iter()
.collect::<BTreeSet<CorpPermission>>()
| &perm_add)
- &perm_rem)
.into_iter()
.collect();
ctx.trans
.upsert_corp_membership(&corp_id, &target_user.item_code, &their_mem)
.await?;
Ok(())
}
async fn corp_info(ctx: &mut VerbContext<'_>, remaining: &str) -> UResult<()> {
let user = get_user_or_fail(ctx)?;
let (corp_id, corp, _) = match ctx
.trans
.match_user_corp_by_name(remaining.trim(), &user.username)
.await?
{
None => user_error("You don't seem to belong to a matching corp!".to_owned())?,
Some(c) => c,
};
let mut msg = String::new();
let founded_ago = humantime::format_duration(std::time::Duration::from_secs(
(Utc::now() - corp.founded).num_seconds() as u64,
));
msg.push_str(&format!(
ansi!("<bold>{}'s essential information<reset>\nFounded: {} ago\nCredits: ${}\nTax rate: {}%\n"),
&corp.name, &founded_ago, corp.credits, corp.tax as f64 * 1E-2
));
let members = ctx.trans.list_corp_members(&corp_id).await?;
if corp.allow_combat_required {
msg.push_str(
"Members ARE required to allow the corp to consent to fighting on their behalf",
);
let tot_mem = members.len();
let mem_allow_combat = members.iter().filter(|(_, m)| m.allow_combat).count();
if tot_mem == mem_allow_combat {
msg.push_str(", and all members have done so.\n");
} else {
msg.push_str("- waiting on the following members to do so: ");
for (u, _) in members.iter().filter(|(_, m)| !m.allow_combat) {
msg.push_str(&caps_first(&u));
msg.push(' ');
}
msg.push('\n');
}
} else {
msg.push_str(
"Members are NOT required to allow the corp to consent to fighting on their behalf.\n",
);
}
msg.push_str(&format!(
"Base member privileges: {}\n",
&corp
.member_permissions
.iter()
.map(|p| p.display())
.join(" ")
));
msg.push_str("Members:\n");
msg.push_str(&format!(
ansi!("<bgblue><white><bold>| {:20} | {:20} | {:20} |<reset>\n"),
"Name", "Title", "Privileges"
));
for (user, mem) in members {
msg.push_str(&format!(
ansi!("| {:20} | {:20} | {:20} |\n"),
caps_first(&user),
mem.job_title,
mem.permissions.iter().map(|p| p.display()).join(" ")
));
}
ctx.trans
.queue_for_session(&ctx.session, Some(&msg))
.await?;
Ok(())
}
async fn corp_allow_combat(ctx: &mut VerbContext<'_>, remaining: &str) -> UResult<()> {
let (command, remaining) = parse_command_name(remaining);
if command != "from" {
user_error("Usage: corp allow combat from corpname".to_owned())?;
}
let user = get_user_or_fail(ctx)?;
let (corp_id, corp, mut mem) = match ctx
.trans
.match_user_corp_by_name(remaining.trim(), &user.username)
.await?
{
None => user_error("You don't seem to belong to a matching corp!".to_owned())?,
Some(c) => c,
};
if mem.allow_combat {
user_error(format!("You already allow combat for {}.", &corp.name))?;
}
mem.allow_combat = true;
ctx.trans
.upsert_corp_membership(&corp_id, &user.username.to_lowercase(), &mem)
.await?;
ctx.trans
.queue_for_session(
&ctx.session,
Some("You now allow the corp to consent to fighting on your behalf.\n"),
)
.await?;
Ok(())
}
async fn corp_disallow_combat(ctx: &mut VerbContext<'_>, remaining: &str) -> UResult<()> {
let (command, remaining) = parse_command_name(remaining);
if command != "from" {
user_error("Usage: corp disallow combat from corpname".to_owned())?
}
let user = get_user_or_fail(ctx)?;
let (corp_id, corp, mut mem) = match ctx
.trans
.match_user_corp_by_name(remaining.trim(), &user.username)
.await?
{
None => user_error("You don't seem to belong to a matching corp!".to_owned())?,
Some(c) => c,
};
if !mem.allow_combat {
user_error("You already disallow combat for that corp.".to_owned())?;
}
if corp.allow_combat_required {
user_error(format!(
ansi!(
"That corp requires everyone to allow combat as a condition \
of employment, so you can't do that. You could do \
<bold>corp resign {}<reset> instead."
),
&corp.name
))?;
}
mem.allow_combat = false;
ctx.trans
.upsert_corp_membership(&corp_id, &user.username.to_lowercase(), &mem)
.await?;
ctx.trans
.queue_for_session(
&ctx.session,
Some("You no longer allow the corp to consent to fighting on your behalf.\n"),
)
.await?;
Ok(())
}
async fn corp_config(ctx: &mut VerbContext<'_>, remaining: &str) -> UResult<()> {
let (corp_name_raw, remaining) = match remaining.split_once(" ") {
None => user_error("Usage: corp config corpname ...".to_owned())?,
Some(v) => v,
};
let remaining = remaining.trim().to_lowercase();
let user = get_user_or_fail(ctx)?;
let (corp_id, mut corp, mem) = match ctx
.trans
.match_user_corp_by_name(corp_name_raw.trim(), &user.username)
.await?
{
None => user_error("You don't seem to belong to a matching corp!".to_owned())?,
Some(c) => c,
};
if !check_corp_perm(&CorpPermission::Configure, &mem) {
user_error("You don't have permission to change settings for that corp.".to_owned())?;
}
if remaining == "allow combat required" {
corp.allow_combat_required = true;
ctx.trans
.broadcast_to_corp(
&corp_id,
&CorpCommType::Notice,
None,
&format!(
"{} announces a policy decision for {}: all new members must allow the \
corp to consent to fights on their behalf.\n",
&caps_first(&user.username),
&corp.name
),
)
.await?;
ctx.trans.update_corp_details(&corp_id, &corp).await?;
} else if remaining == "allow combat not required" {
corp.allow_combat_required = false;
ctx.trans.update_corp_details(&corp_id, &corp).await?;
ctx.trans.broadcast_to_corp(
&corp_id,
&CorpCommType::Notice, None,
&format!("{} announces a policy decision for {}: new members no longer need to allow the \
corp to consent to fights on their behalf. Any member can now use \
\"corp disallow combat from {}\" if they want to.\n",
&caps_first(&user.username),
&corp.name, &corp.name)).await?;
} else if remaining.starts_with("base privileges ") {
let remaining = remaining[("base privileges ".len())..].trim();
let psplit = remaining.split(" ");
let mut perms: BTreeSet<CorpPermission> = BTreeSet::new();
for perm in psplit {
let perm = ignore_special_characters(&perm.trim()).to_lowercase();
if perm == "none" {
continue;
}
let perm = match CorpPermission::parse(&perm) {
None => user_error(format!("Unknown permission {}", perm))?,
Some(p) if p == CorpPermission::Holder => {
user_error("You can't set holder as a base privilege.".to_owned())?
}
Some(p) if !check_corp_perm(&p, &mem) => {
user_error("You can't set base privilege you don't have yourself.".to_owned())?
}
Some(p) => p,
};
perms.insert(perm);
}
corp.member_permissions = perms.clone().into_iter().collect();
let perm_str = &corp
.member_permissions
.iter()
.map(|p| p.display())
.join(" ");
ctx.trans
.broadcast_to_corp(
&corp_id,
&CorpCommType::Notice,
None,
&format!(
"{} announces a policy decision for {}: new members get \
the following privileges: {}.\n",
&caps_first(&user.username),
&corp.name,
if perm_str == "" { "none" } else { &perm_str }
),
)
.await?;
ctx.trans.update_corp_details(&corp_id, &corp).await?;
} else if remaining.starts_with("tax rate ") {
let remaining = remaining[("tax rate ".len())..].trim();
let remaining = match remaining.split_once("%") {
None => remaining,
Some((r, e)) => {
if e != "" {
user_error("Tax rate must be a number".to_owned())?
} else {
r.trim()
}
}
};
let rate: f32 = match remaining.parse::<f32>() {
Err(_) => user_error("Tax rate must be a number".to_owned())?,
Ok(r) => r,
};
if rate < 0.0 || rate >= 80.0 {
user_error("Tax rate must be 0-80%".to_owned())?;
}
corp.tax = (rate * 100.0) as u16;
ctx.trans.update_corp_details(&corp_id, &corp).await?;
ctx.trans
.broadcast_to_corp(
&corp_id,
&CorpCommType::Notice,
None,
&format!(
"{} has changed the tax rate for {} to {}%\n",
user.username,
corp.name,
(corp.tax as f64) / 100.0
),
)
.await?;
} else {
user_error("Configurations you can set:\n\tallow combat required\n\tallow combat not required\nbase permissions permission_name permission_name\ntax rate 12.34%".to_owned())?;
}
Ok(())
}
async fn corp_subscribe(ctx: &mut VerbContext<'_>, remaining: &str) -> UResult<()> {
let user = get_user_or_fail(ctx)?;
let (subs, remaining) = match remaining.split_once(" from ") {
None => {
let (_, corp, mem) = match ctx
.trans
.match_user_corp_by_name(remaining.trim(), &user.username)
.await?
{
None => user_error("You don't seem to belong to a matching corp!".to_owned())?,
Some(c) => c,
};
ctx.trans
.queue_for_session(
&ctx.session,
Some(&format!(
"Subscriptions for {}: {}\n",
&corp.name,
&mem.comms_on.iter().map(|m| m.display()).join(" ")
)),
)
.await?;
return Ok(());
}
Some(v) => v,
};
let mut subs_add = BTreeSet::<CorpCommType>::new();
for sub in subs.trim().split(" ") {
let sub = ignore_special_characters(&sub.trim());
match CorpCommType::parse(&sub) {
None => user_error(format!("Invalid commtype: {}", sub))?,
Some(t) => subs_add.insert(t),
};
}
let (corp_id, _, mut mem) = match ctx
.trans
.match_user_corp_by_name(remaining.trim(), &user.username)
.await?
{
None => user_error("You don't seem to belong to a matching corp!".to_owned())?,
Some(c) => c,
};
mem.comms_on = (&mem.comms_on.into_iter().collect::<BTreeSet<CorpCommType>>() | &subs_add)
.into_iter()
.collect();
ctx.trans
.upsert_corp_membership(&corp_id, &user.username, &mem)
.await?;
ctx.trans
.queue_for_session(&ctx.session, Some("Subscriptions updated.\n"))
.await?;
Ok(())
}
async fn corp_unsubscribe(ctx: &mut VerbContext<'_>, remaining: &str) -> UResult<()> {
let (subs, remaining) = match remaining.split_once(" from ") {
None => user_error(
"Usage: corp unsubscribe commtype commtype ... from corpname\n\
commtypes: chat notice connect reward death consent"
.to_owned(),
)?,
Some(v) => v,
};
let mut subs_del = BTreeSet::<CorpCommType>::new();
for sub in subs.trim().split(" ") {
let sub = ignore_special_characters(&sub.trim());
match CorpCommType::parse(&sub) {
None => user_error(format!("Invalid commtype: {}", sub))?,
Some(t) => subs_del.insert(t),
};
}
let user = get_user_or_fail(ctx)?;
let (corp_id, _, mut mem) = match ctx
.trans
.match_user_corp_by_name(remaining.trim(), &user.username)
.await?
{
None => user_error("You don't seem to belong to a matching corp!".to_owned())?,
Some(c) => c,
};
mem.comms_on = (&mem.comms_on.into_iter().collect::<BTreeSet<CorpCommType>>() - &subs_del)
.into_iter()
.collect();
ctx.trans
.upsert_corp_membership(&corp_id, &user.username, &mem)
.await?;
ctx.trans
.queue_for_session(&ctx.session, Some("Subscriptions updated.\n"))
.await?;
Ok(())
}
async fn corp_order(ctx: &mut VerbContext<'_>, remaining: &str) -> UResult<()> {
let (corpname, number_s) = match remaining.split_once(" as ") {
None => user_error("Usage: corp order corpname as number".to_owned())?,
Some(v) => v,
};
let number = match nom::character::complete::i64::<&str, ()>(number_s) {
Ok((rest, num)) if rest.trim() == "" => num,
_ => user_error("Usage: corp order corpname as number".to_owned())?,
};
let user = get_user_or_fail(ctx)?;
let (corp_id, _, mut mem) = match ctx
.trans
.match_user_corp_by_name(corpname.trim(), &user.username)
.await?
{
None => user_error("You don't seem to belong to a matching corp!".to_owned())?,
Some(c) => c,
};
mem.priority = number;
ctx.trans
.upsert_corp_membership(&corp_id, &user.username, &mem)
.await?;
ctx.trans
.queue_for_session(&ctx.session, Some("Updated!\n"))
.await?;
Ok(())
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let (command, remaining) = parse_command_name(remaining);
match command.to_lowercase().as_str() {
"hire" | "invite" => corp_invite(ctx, remaining).await?,
"join" => corp_join(ctx, remaining).await?,
"" | "list" => corp_list(ctx, remaining).await?,
"leave" | "resign" => corp_leave(ctx, remaining).await?,
"fire" | "dismiss" => corp_fire(ctx, remaining).await?,
"promote" | "demote" => corp_promote(ctx, remaining).await?,
"info" => corp_info(ctx, remaining).await?,
"allow" => {
let (command, remaining) = parse_command_name(remaining);
if command.to_lowercase() != "combat" {
user_error("Allow must be followed with combat".to_owned())?
} else {
corp_allow_combat(ctx, remaining).await?
}
}
"disallow" => {
let (command, remaining) = parse_command_name(remaining);
if command.to_lowercase() != "combat" {
user_error("Disallow must be followed with combat".to_owned())?
} else {
corp_disallow_combat(ctx, remaining).await?
}
}
"configure" | "config" => {
corp_config(ctx, remaining).await?;
}
"sub" | "subscribe" => {
corp_subscribe(ctx, remaining).await?;
}
"unsub" | "unsubscribe" => {
corp_unsubscribe(ctx, remaining).await?;
}
"order" => {
corp_order(ctx, remaining).await?;
}
_ => user_error("Unknown command".to_owned())?,
}
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,358 @@
use super::{
get_player_item_or_fail, search_item_for_user, user_error, UResult, UserError, UserVerb,
UserVerbRef, VerbContext,
};
#[double]
use crate::db::DBTrans;
use crate::{
db::ItemSearchParams,
language::join_words,
models::item::{DeathData, Item, SkillType},
regular_tasks::queued_command::{
queue_command_and_save, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::{
capacity::{check_item_capacity, CapacityLevel},
combat::corpsify_item,
comms::broadcast_to_room,
destroy_container,
skills::skill_check_and_grind,
urges::change_stress_considering_cool,
},
static_content::possession_type::{can_butcher_possessions, possession_data},
};
use ansi::ansi;
use async_trait::async_trait;
use mockall_double::double;
use std::{sync::Arc, time};
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error(
"You butcher things while they are dead, not while YOU are dead!".to_owned(),
)?;
}
if ctx.item.urges.as_ref().map(|u| u.stress.value).unwrap_or(0) > 7000 {
user_error(
ansi!(
"You are too tired and stressed to consider crafts. Maybe try to \
<bold>sit<reset> or <bold>recline<reset> for a bit!"
)
.to_owned(),
)?;
}
let (from_corpse_id, what_part) = match ctx.command {
QueueCommand::Cut {
from_corpse,
what_part,
} => (from_corpse, what_part),
_ => user_error("Unexpected command".to_owned())?,
};
let corpse = match ctx
.trans
.find_item_by_type_code("corpse", &from_corpse_id)
.await?
{
None => user_error("The corpse seems to be gone".to_owned())?,
Some(it) => it,
};
if corpse.location != ctx.item.location {
user_error(format!(
"You try to cut {} but realise it is no longer there.",
corpse.display_for_sentence(1, false)
))?
}
ensure_has_butcher_tool(&ctx.trans, &ctx.item).await?;
match corpse.death_data.as_ref() {
None => user_error(format!(
"You can't do that while {} is still alive!",
corpse.pronouns.subject
))?,
Some(DeathData {
parts_remaining, ..
}) => {
if !parts_remaining.iter().any(|pt| {
possession_data()
.get(pt)
.map(|pd| &pd.display == &what_part)
== Some(true)
}) {
user_error(format!(
"That part ({}) is now gone. Parts you can cut: {}",
&what_part,
&join_words(
&parts_remaining
.iter()
.filter_map(|pt| possession_data().get(pt))
.map(|pd| pd.display)
.collect::<Vec<&'static str>>()
)
))?;
}
}
};
let msg = format!(
"{} prepares to cut {} from {}\n",
&ctx.item.display_for_sentence(1, true),
&what_part,
&corpse.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error(
"You butcher things while they are dead, not while YOU are dead!".to_owned(),
)?;
}
let (from_corpse_id, what_part) = match ctx.command {
QueueCommand::Cut {
from_corpse,
what_part,
} => (from_corpse, what_part),
_ => user_error("Unexpected command".to_owned())?,
};
ensure_has_butcher_tool(&ctx.trans, &ctx.item).await?;
let corpse = match ctx
.trans
.find_item_by_type_code("corpse", &from_corpse_id)
.await?
{
None => user_error("The corpse seems to be gone".to_owned())?,
Some(it) => it,
};
if corpse.location != ctx.item.location {
user_error(format!(
"You try to cut {} but realise it is no longer there.",
corpse.display_for_sentence(1, false)
))?
}
let possession_type = match corpse.death_data.as_ref() {
None => user_error(format!(
"You can't do that while {} is still alive!",
corpse.pronouns.subject
))?,
Some(DeathData {
parts_remaining, ..
}) => parts_remaining
.iter()
.find(|pt| {
possession_data()
.get(pt)
.map(|pd| &pd.display == &what_part)
== Some(true)
})
.ok_or_else(|| {
UserError(format!(
"Parts you can cut: {}",
&join_words(
&parts_remaining
.iter()
.filter_map(|pt| possession_data().get(pt))
.map(|pd| pd.display)
.collect::<Vec<&'static str>>()
)
))
})?,
};
let possession_data = possession_data()
.get(possession_type)
.ok_or_else(|| UserError("That part doesn't exist anymore".to_owned()))?;
let mut corpse_mut = (*corpse).clone();
match corpse_mut.death_data.as_mut() {
None => {}
Some(dd) => {
dd.parts_remaining = dd
.parts_remaining
.iter()
.take_while(|pt| pt != &possession_type)
.chain(
dd.parts_remaining
.iter()
.skip_while(|pt| pt != &possession_type)
.skip(1),
)
.map(|pt| (*pt).clone())
.collect()
}
}
match check_item_capacity(&ctx.trans, &ctx.item, possession_data.weight).await? {
CapacityLevel::AboveItemLimit | CapacityLevel::OverBurdened => {
user_error("You have too much stuff to take that on!".to_owned())?
}
_ => {}
}
if corpse_mut
.death_data
.as_ref()
.map(|dd| dd.parts_remaining.is_empty())
== Some(true)
{
destroy_container(&ctx.trans, &corpse_mut).await?;
} else {
ctx.trans.save_item_model(&corpse_mut).await?;
}
if skill_check_and_grind(&ctx.trans, ctx.item, &SkillType::Craft, 10.0).await? < 0.0 {
change_stress_considering_cool(&ctx.trans, &mut ctx.item, 500).await?;
broadcast_to_room(
&ctx.trans,
&ctx.item.location,
None,
&format!(
"{} tries to cut the {} from {}, but only leaves a mutilated mess.\n",
&ctx.item.display_for_sentence(1, true),
possession_data.display,
corpse.display_for_sentence(1, false)
),
)
.await?;
} else {
let mut new_item: Item = (*possession_type).clone().into();
new_item.item_code = format!("{}", ctx.trans.alloc_item_code().await?);
new_item.location = ctx.item.refstr();
ctx.trans.save_item_model(&new_item).await?;
broadcast_to_room(
&ctx.trans,
&ctx.item.location,
None,
&format!(
"{} expertly cuts the {} from {}.\n",
&ctx.item.display_for_sentence(1, true),
possession_data.display,
corpse.display_for_sentence(1, false)
),
)
.await?;
}
Ok(())
}
}
pub async fn ensure_has_butcher_tool(trans: &DBTrans, player_item: &Item) -> UResult<()> {
if trans
.count_matching_possessions(&player_item.refstr(), &can_butcher_possessions())
.await?
< 1
{
user_error("You have nothing sharp on you suitable for butchery!".to_owned())?;
}
Ok(())
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let (what_raw, corpse_raw) = match remaining.split_once(" from ") {
None => user_error(
ansi!("Usage: <bold>cut<reset> thing <bold>from<reset> corpse").to_owned(),
)?,
Some(v) => v,
};
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error(
"You butcher things while they are dead, not while YOU are dead!".to_owned(),
)?
}
let possible_corpse = search_item_for_user(
ctx,
&ItemSearchParams {
include_loc_contents: true,
dead_first: true,
..ItemSearchParams::base(&player_item, corpse_raw.trim())
},
)
.await?;
let what_norm = what_raw.trim().to_lowercase();
let possession_type = match possible_corpse.death_data.as_ref() {
None => user_error(format!(
"You can't do that while {} is still alive!",
possible_corpse.pronouns.subject
))?,
Some(DeathData {
parts_remaining, ..
}) => parts_remaining
.iter()
.find(|pt| {
possession_data().get(pt).map(|pd| {
pd.display.to_lowercase() == what_norm
|| pd.aliases.iter().any(|a| a.to_lowercase() == what_norm)
}) == Some(true)
})
.ok_or_else(|| {
UserError(format!(
"Parts you can cut: {}",
&join_words(
&parts_remaining
.iter()
.filter_map(|pt| possession_data().get(pt))
.map(|pd| pd.display)
.collect::<Vec<&'static str>>()
)
))
})?,
}
.clone();
let corpse = if possible_corpse.item_type == "corpse" {
possible_corpse
} else if possible_corpse.item_type == "npc" || possible_corpse.item_type == "player" {
let mut possible_corpse_mut = (*possible_corpse).clone();
possible_corpse_mut.location = if possible_corpse.item_type == "npc" {
"room/valhalla"
} else {
"room/repro_xv_respawn"
}
.to_owned();
Arc::new(corpsify_item(&ctx.trans, &possible_corpse).await?)
} else {
user_error("You can't butcher that!".to_owned())?
};
let possession_data = possession_data()
.get(&possession_type)
.ok_or_else(|| UserError("That part doesn't exist anymore".to_owned()))?;
ensure_has_butcher_tool(&ctx.trans, &player_item).await?;
queue_command_and_save(
ctx,
&player_item,
&QueueCommand::Cut {
from_corpse: corpse.item_code.clone(),
what_part: possession_data.display.to_owned(),
},
)
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,212 @@
use std::collections::BTreeMap;
use super::{
follow::cancel_follow_by_leader, get_player_item_or_fail, get_user_or_fail, look,
rent::recursively_destroy_or_move_item, user_error, UResult, UserError, UserVerb, UserVerbRef,
VerbContext,
};
use crate::{
models::task::{Task, TaskDetails, TaskMeta},
regular_tasks::{TaskHandler, TaskRunContext},
services::{
combat::{corpsify_item, handle_death},
destroy_container,
skills::calculate_total_stats_skills_for_user,
},
DResult,
};
use ansi::ansi;
use async_trait::async_trait;
use chrono::Utc;
use rand::{distributions::Alphanumeric, Rng};
use std::time;
async fn verify_code(
ctx: &mut VerbContext<'_>,
input: &str,
base_command: &str,
action_text: &str,
) -> UResult<bool> {
let user_dat = ctx
.user_dat
.as_mut()
.ok_or(UserError("Please log in first".to_owned()))?;
let code = match &user_dat.danger_code {
None => {
let new_code = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
.collect::<String>();
user_dat.danger_code = Some(new_code.clone());
new_code
}
Some(code) => code.clone(),
};
let input_tr = input.trim();
if input_tr == "" || !input_tr.starts_with("code ") {
ctx.trans
.queue_for_session(
ctx.session,
Some(&format!(
ansi!("To verify you want to {}, type <bold>delete {} code {}<reset>\n"),
action_text, base_command, code
)),
)
.await?;
ctx.trans.save_user_model(&user_dat).await?;
return Ok(false);
}
if input_tr["code ".len()..].trim() != code {
ctx.trans
.queue_for_session(
ctx.session,
Some(&format!(
ansi!(
"Your confirmation code didn't match! \
To verify you want to {}, type <bold>delete {} code {}<reset>\n"
),
action_text, base_command, code
)),
)
.await?;
ctx.trans.save_user_model(&user_dat).await?;
return Ok(false);
}
Ok(true)
}
async fn reset_stats(ctx: &mut VerbContext<'_>) -> UResult<()> {
let mut player_item = (*get_player_item_or_fail(ctx).await?).clone();
let user_dat = ctx
.user_dat
.as_mut()
.ok_or(UserError("Please log in first".to_owned()))?;
handle_death(&ctx.trans, &mut player_item).await?;
corpsify_item(&ctx.trans, &player_item).await?;
player_item.death_data = None;
player_item.location = "room/repro_xv_chargen".to_owned();
player_item.total_xp = ((player_item.total_xp as i64)
- user_dat.experience.xp_change_for_this_reroll)
.max(0) as u64;
player_item.urges = Some(Default::default());
user_dat.experience.xp_change_for_this_reroll = 0;
user_dat.raw_stats = BTreeMap::new();
user_dat.raw_skills = BTreeMap::new();
user_dat.wristpad_hacks = vec![];
user_dat.scan_codes = vec![];
user_dat.quest_progress = None;
calculate_total_stats_skills_for_user(&mut player_item, &user_dat);
ctx.trans.save_user_model(&user_dat).await?;
ctx.trans.save_item_model(&player_item).await?;
look::VERB.handle(ctx, "look", "").await?;
Ok(())
}
#[derive(Clone)]
pub struct DestroyUserHandler;
pub static DESTROY_USER_HANDLER: &'static (dyn TaskHandler + Sync + Send) = &DestroyUserHandler;
#[async_trait]
impl TaskHandler for DestroyUserHandler {
async fn do_task(&self, ctx: &mut TaskRunContext) -> DResult<Option<time::Duration>> {
let username = match &ctx.task.details {
TaskDetails::DestroyUser { username } => username.clone(),
_ => {
return Ok(None);
}
};
let _user = match ctx.trans.find_by_username(&username).await? {
None => return Ok(None),
Some(u) => u,
};
let player_item = match ctx
.trans
.find_item_by_type_code("player", &username.to_lowercase())
.await?
{
None => return Ok(None),
Some(p) => p,
};
cancel_follow_by_leader(&ctx.trans, &player_item.refstr()).await?;
destroy_container(&ctx.trans, &player_item).await?;
for dynzone in ctx
.trans
.find_dynzone_for_owner(&format!("player/{}", &username.to_lowercase()))
.await?
{
recursively_destroy_or_move_item(&ctx.trans, &dynzone).await?;
}
ctx.trans.delete_user(&username).await?;
Ok(None)
}
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let rtrim = remaining.trim();
let username = get_user_or_fail(ctx)?.username.clone();
if rtrim.starts_with("character forever") {
if !verify_code(ctx, &rtrim["character forever".len()..], "character forever",
"permanently destroy your character (after a one week reflection period), making the name available for other players").await? {
return Ok(());
}
ctx.trans
.upsert_task(&Task {
meta: TaskMeta {
task_code: username.clone(),
next_scheduled: Utc::now() + chrono::TimeDelta::try_days(7).unwrap(),
..Default::default()
},
details: TaskDetails::DestroyUser { username },
})
.await?;
ctx.trans
.queue_for_session(
ctx.session,
Some(
"Puny human, your permanent destruction has been scheduled \
for one week from now! If you change your mind, just log \
in again before the week is up. After one week, your username \
will be available for others to take, and you will need to start \
again with a new character. This character will count towards the \
character limit for the week, but will not once it is deleted. \
Goodbye forever!\n",
),
)
.await?;
ctx.trans.queue_for_session(ctx.session, None).await?;
} else if rtrim.starts_with("stats") {
if !verify_code(ctx, &rtrim["stats".len()..], "stats",
"kill your character, reset your stats and non-journal XP, and pick new stats to reclone with").await? {
return Ok(());
}
reset_stats(ctx).await?;
} else {
user_error(
ansi!("Try <bold>delete character forever<reset> or <bold>delete stats<reset>")
.to_owned(),
)?
}
let user_dat = ctx
.user_dat
.as_mut()
.ok_or(UserError("Please log in first".to_owned()))?;
user_dat.danger_code = None;
ctx.trans.save_user_model(&user_dat).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -1,19 +1,19 @@
use super::{ use super::{
get_player_item_or_fail, parsing::parse_to_space, user_error, UResult, UserVerb, UserVerbRef,
VerbContext, VerbContext,
UserVerb,
UserVerbRef,
UResult,
parsing::parse_to_space,
user_error,
get_player_item_or_fail
}; };
use async_trait::async_trait;
use ansi::{ansi, ignore_special_characters}; use ansi::{ansi, ignore_special_characters};
use async_trait::async_trait;
pub struct Verb; pub struct Verb;
#[async_trait] #[async_trait]
impl UserVerb for Verb { impl UserVerb for Verb {
async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let (me, remaining) = parse_to_space(remaining); let (me, remaining) = parse_to_space(remaining);
let (as_word, remaining) = parse_to_space(remaining); let (as_word, remaining) = parse_to_space(remaining);
let remaining = ignore_special_characters(remaining.trim()); let remaining = ignore_special_characters(remaining.trim());
@ -22,17 +22,28 @@ impl UserVerb for Verb {
} }
if remaining.len() < 40 { if remaining.len() < 40 {
user_error(format!("That's too short by {} characters.", 40 - remaining.len()))?; user_error(format!(
"That's too short by {} characters.",
40 - remaining.len()
))?;
} }
if remaining.len() > 255 { if remaining.len() > 255 {
user_error(format!("That's too short by {} characters.", remaining.len() - 255))?; user_error(format!(
"That's too short by {} characters.",
remaining.len() - 255
))?;
} }
let mut item = (*get_player_item_or_fail(ctx).await?).clone(); let mut item = (*get_player_item_or_fail(ctx).await?).clone();
item.details = Some(remaining); item.details = Some(remaining);
ctx.trans.save_item_model(&item).await?; ctx.trans.save_item_model(&item).await?;
ctx.trans.queue_for_session(ctx.session, Some(ansi!("<green>Character description updated.<reset>\n"))).await?; ctx.trans
.queue_for_session(
ctx.session,
Some(ansi!("<green>Character description updated.<reset>\n")),
)
.await?;
Ok(()) Ok(())
} }

View File

@ -0,0 +1,236 @@
use super::{
get_player_item_or_fail, parsing::parse_count, search_items_for_user, user_error,
ItemSearchParams, UResult, UserError, UserVerb, UserVerbRef, VerbContext,
};
use crate::{
regular_tasks::queued_command::{
queue_command_and_save, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::{
capacity::recalculate_container_weight_mut,
comms::broadcast_to_room,
urges::{hunger_changed, thirst_changed},
},
};
use ansi::ansi;
use async_trait::async_trait;
use std::{collections::BTreeMap, time};
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error(
"You try to drink it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let (item_type, item_code) = match ctx.command {
QueueCommand::Drink {
item_type,
item_code,
} => (item_type, item_code),
_ => user_error("Unexpected command".to_owned())?,
};
let item = match ctx
.trans
.find_item_by_type_code(&item_type, &item_code)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != ctx.item.location && item.location != ctx.item.refstr() {
user_error(format!(
"You try to drink {} but realise you no longer have it",
item.display_for_sentence(1, false)
))?
}
let msg = format!(
"{} prepares to drink from {}\n",
&ctx.item.display_for_sentence(1, true),
&item.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error(
"You try to drink it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let (item_type, item_code) = match ctx.command {
QueueCommand::Drink {
item_type,
item_code,
} => (item_type, item_code),
_ => user_error("Unexpected command".to_owned())?,
};
let item = match ctx
.trans
.find_item_by_type_code(&item_type, &item_code)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != ctx.item.location && item.location != ctx.item.refstr() {
user_error(format!(
"You try to drink {} but realise you no longer have it!",
&item.display_for_sentence(1, false)
))?
}
let liquid_details = item
.liquid_details
.as_ref()
.ok_or_else(|| UserError("You try to drink, but it's empty!".to_owned()))?;
let mut it = liquid_details.contents.iter();
let contents = it
.next()
.ok_or_else(|| UserError("You try to drink, but it's empty!".to_owned()))?;
let contents_2 = it.next();
if !contents_2.is_none() {
user_error("It seems to be a weird mixture of different fluids... you are not sure you should drink it!".to_owned())?;
}
let drink_data = match contents.0.drink_data() {
None => user_error(format!(
"It smells like {}... you are not sure you should drink it!",
contents.0.display()
))?,
Some(v) => v,
};
let urges = ctx
.item
.urges
.as_ref()
.ok_or_else(|| UserError("You don't seem to have the thirst.".to_owned()))?;
if (urges.thirst.value as i16) < -drink_data.thirst_impact {
user_error("You don't seem to have the thirst.".to_owned())?;
}
let how_many_left = (if contents.1 <= &1 {
1
} else {
contents.1.clone()
}) as u64;
let how_many_to_fill = if drink_data.thirst_impact >= 0 {
1
} else {
urges.thirst.value / ((-drink_data.thirst_impact) as u16)
};
let how_many_drunk = how_many_to_fill.min(how_many_left.min(10000) as u16).max(1);
let msg = format!(
"{} drinks from {}\n",
&ctx.item.display_for_sentence(1, true),
&item.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
if let Some(urges) = ctx.item.urges.as_mut() {
urges.hunger.last_value = urges.hunger.value;
urges.hunger.value = (urges.hunger.value as i64
+ (how_many_drunk as i64) * (drink_data.hunger_impact as i64))
.clamp(0, 10000) as u16;
urges.thirst.last_value = urges.thirst.value;
urges.thirst.value = (urges.thirst.value as i64
+ (how_many_drunk as i64) * (drink_data.thirst_impact as i64))
.clamp(0, 10000) as u16;
}
hunger_changed(&ctx.trans, &ctx.item).await?;
thirst_changed(&ctx.trans, &ctx.item).await?;
let mut item_mut = (*item).clone();
if let Some(ld) = item_mut.liquid_details.as_mut() {
if (*contents.1) <= how_many_drunk as u64 {
ld.contents = BTreeMap::new();
} else {
ld.contents
.entry(contents.0.clone())
.and_modify(|v| *v -= how_many_drunk as u64);
}
}
match item_mut.liquid_details.as_mut() {
None => {}
Some(ld) => {
ld.contents = ld
.contents
.clone()
.into_iter()
.filter(|c| c.1 != 0)
.collect()
}
}
recalculate_container_weight_mut(&ctx.trans, &mut item_mut).await?;
ctx.trans.save_item_model(&item_mut).await?;
Ok(())
}
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
mut remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
if !remaining.starts_with("from ") {
user_error(ansi!("Try <bold>drink from<reset> container.").to_owned())?;
}
remaining = remaining[5..].trim();
let mut drink_limit = Some(1);
if remaining == "all" || remaining.starts_with("all ") {
remaining = remaining[3..].trim();
drink_limit = None;
} else if let (Some(n), remaining2) = parse_count(remaining) {
drink_limit = Some(n);
remaining = remaining2;
}
let targets = search_items_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
include_loc_contents: true,
limit: drink_limit.unwrap_or(100),
..ItemSearchParams::base(&player_item, &remaining)
},
)
.await?;
if player_item.death_data.is_some() {
user_error(
"You try to drink it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
for target in targets {
if target.item_type != "possession" && target.item_type != "fixed_item" {
user_error("You can't drink that!".to_owned())?;
}
if target.liquid_details.is_none() {
user_error("There's nothing to drink!".to_owned())?;
}
queue_command_and_save(
ctx,
&player_item,
&QueueCommand::Drink {
item_type: target.item_type.clone(),
item_code: target.item_code.clone(),
},
)
.await?;
}
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,270 @@
use super::{
get_player_item_or_fail, parsing::parse_count, search_items_for_user, user_error,
ItemSearchParams, UResult, UserVerb, UserVerbRef, VerbContext,
};
#[double]
use crate::db::DBTrans;
use crate::{
models::{
item::{Item, ItemFlag, LocationActionType},
task::{Task, TaskDetails, TaskMeta},
},
regular_tasks::{
queued_command::{queue_command, QueueCommand, QueueCommandHandler, QueuedCommandContext},
TaskHandler, TaskRunContext,
},
services::{
capacity::{check_item_ref_capacity, CapacityLevel},
comms::broadcast_to_room,
},
static_content::possession_type::possession_data,
DResult,
};
use ansi::ansi;
use async_trait::async_trait;
use chrono::Utc;
use mockall_double::double;
use std::time;
pub struct ExpireItemTaskHandler;
#[async_trait]
impl TaskHandler for ExpireItemTaskHandler {
async fn do_task(&self, ctx: &mut TaskRunContext) -> DResult<Option<time::Duration>> {
let item_code = match &mut ctx.task.details {
TaskDetails::ExpireItem { item_code } => item_code,
_ => Err("Expected ExpireItem type")?,
};
let item = match ctx
.trans
.find_item_by_type_code("possession", item_code)
.await?
{
None => {
return Ok(None);
}
Some(it) => it,
};
let (loc_type, loc_code) = match item.location.split_once("/") {
None => return Ok(None),
Some(p) => p,
};
if loc_type != "room" {
return Ok(None);
}
let loc_item = match ctx.trans.find_item_by_type_code(loc_type, loc_code).await? {
None => return Ok(None),
Some(i) => i,
};
if loc_item.flags.contains(&ItemFlag::DroppedItemsDontExpire) {
return Ok(None);
}
ctx.trans.delete_item("possession", item_code).await?;
Ok(None)
}
}
pub static EXPIRE_ITEM_HANDLER: &'static (dyn TaskHandler + Sync + Send) = &ExpireItemTaskHandler;
pub async fn consider_expire_job_for_item(trans: &DBTrans, item: &Item) -> DResult<()> {
let (loc_type, loc_code) = match item.location.split_once("/") {
None => return Ok(()),
Some(p) => p,
};
if loc_type != "room" {
return Ok(());
}
let loc_item = match trans.find_item_by_type_code(loc_type, loc_code).await? {
None => return Ok(()),
Some(i) => i,
};
if loc_item.flags.contains(&ItemFlag::DroppedItemsDontExpire) {
return Ok(());
}
trans
.upsert_task(&Task {
meta: TaskMeta {
task_code: format!("{}/{}", item.item_type, item.item_code),
next_scheduled: Utc::now() + chrono::TimeDelta::try_hours(1).unwrap(),
..Default::default()
},
details: TaskDetails::ExpireItem {
item_code: item.item_code.clone(),
},
})
.await?;
Ok(())
}
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error(
"You try to drop it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let item_id = match ctx.command {
QueueCommand::Drop { possession_id } => possession_id,
_ => user_error("Unexpected command".to_owned())?,
};
let item = match ctx
.trans
.find_item_by_type_code("possession", &item_id)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != format!("{}/{}", &ctx.item.item_type, &ctx.item.item_code) {
user_error(format!(
"You try to drop {} but realise you no longer have it",
item.display_for_sentence(1, false)
))?
}
if item.action_type == LocationActionType::Worn {
user_error(
ansi!("You're wearing it - try using <bold>remove<reset> first").to_owned(),
)?;
}
let msg = format!(
"{} prepares to drop {}\n",
&ctx.item.display_for_sentence(1, true),
&item.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error(
"You try to get it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let item_id = match ctx.command {
QueueCommand::Drop { possession_id } => possession_id,
_ => user_error("Unexpected command".to_owned())?,
};
let item = match ctx
.trans
.find_item_by_type_code("possession", &item_id)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != format!("{}/{}", &ctx.item.item_type, &ctx.item.item_code) {
user_error(format!(
"You try to drop {} but realise you no longer have it!",
&item.display_for_sentence(1, false)
))?
}
if item.action_type == LocationActionType::Worn {
user_error(
ansi!("You're wearing it - try using <bold>remove<reset> first").to_owned(),
)?;
}
let possession_data = match item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(&pt))
{
None => {
user_error("That item no longer exists in the game so can't be handled".to_owned())?
}
Some(pd) => pd,
};
match check_item_ref_capacity(ctx.trans, &ctx.item.location, possession_data.weight).await?
{
CapacityLevel::AboveItemLimit => user_error(format!(
"You can't drop {}, because it is so cluttered here there is no where to put it!",
&item.display_for_sentence(1, false)
))?,
_ => (),
}
let msg = format!(
"{} drops {}\n",
&ctx.item.display_for_sentence(1, true),
&item.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
let mut item_mut = (*item).clone();
item_mut.location = ctx.item.location.clone();
consider_expire_job_for_item(ctx.trans, &item_mut).await?;
item_mut.action_type = LocationActionType::Normal;
ctx.trans.save_item_model(&item_mut).await?;
Ok(())
}
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
mut remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
let mut get_limit = Some(1);
if remaining == "all" || remaining.starts_with("all ") {
remaining = remaining[3..].trim();
get_limit = None;
} else if let (Some(n), remaining2) = parse_count(remaining) {
get_limit = Some(n);
remaining = remaining2;
}
let targets = search_items_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
item_type_only: Some("possession"),
limit: get_limit.unwrap_or(100),
..ItemSearchParams::base(&player_item, &remaining)
},
)
.await?;
if player_item.death_data.is_some() {
user_error(
"You try to drop it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let mut player_item_mut = (*player_item).clone();
for target in targets {
if target.item_type != "possession" {
user_error("You can't drop that!".to_owned())?;
}
queue_command(
ctx,
&mut player_item_mut,
&QueueCommand::Drop {
possession_id: target.item_code.clone(),
},
)
.await?;
}
ctx.trans.save_item_model(&player_item_mut).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,221 @@
use super::{
get_player_item_or_fail, parsing::parse_count, search_items_for_user, user_error,
ItemSearchParams, UResult, UserError, UserVerb, UserVerbRef, VerbContext,
};
use crate::{
models::item::LocationActionType,
regular_tasks::queued_command::{
queue_command, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::{
comms::broadcast_to_room,
urges::{hunger_changed, thirst_changed},
},
static_content::possession_type::possession_data,
};
use ansi::ansi;
use async_trait::async_trait;
use std::time;
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error(
"You try to eat it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let item_id = match ctx.command {
QueueCommand::Eat { possession_id } => possession_id,
_ => user_error("Unexpected command".to_owned())?,
};
let item = match ctx
.trans
.find_item_by_type_code("possession", &item_id)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != format!("{}/{}", &ctx.item.item_type, &ctx.item.item_code) {
user_error(format!(
"You try to eat {} but realise you no longer have it",
item.display_for_sentence(1, false)
))?
}
let msg = format!(
"{} prepares to eat {}\n",
&ctx.item.display_for_sentence(1, true),
&item.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error(
"You try to eat it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let item_id = match ctx.command {
QueueCommand::Eat { possession_id } => possession_id,
_ => user_error("Unexpected command".to_owned())?,
};
let item = match ctx
.trans
.find_item_by_type_code("possession", &item_id)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != format!("{}/{}", &ctx.item.item_type, &ctx.item.item_code) {
user_error(format!(
"You try to eat {} but realise you no longer have it!",
&item.display_for_sentence(1, false)
))?
}
if item.action_type == LocationActionType::Worn {
user_error(
ansi!("You're wearing it - try using <bold>remove<reset> first").to_owned(),
)?;
}
let possession_data = match item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(&pt))
{
None => {
user_error("That item no longer exists in the game so can't be handled".to_owned())?
}
Some(pd) => pd,
};
let eat_data = possession_data
.eat_data
.as_ref()
.ok_or_else(|| UserError("You can't eat that!".to_owned()))?;
let urges = ctx
.item
.urges
.as_ref()
.ok_or_else(|| UserError("You don't seem to have the appetite.".to_owned()))?;
if (urges.hunger.value as i16) < -eat_data.hunger_impact {
user_error("You don't seem to have the appetite.".to_owned())?;
}
let how_many_left = (if item.charges <= 1 { 1 } else { item.charges }) as u16;
let how_many_to_fill = if eat_data.hunger_impact >= 0 {
1
} else {
urges.hunger.value / ((-eat_data.hunger_impact) as u16)
};
let how_many_eaten = how_many_to_fill.min(how_many_left).max(1);
let msg = format!(
"{} {} {}\n",
&ctx.item.display_for_sentence(1, true),
&(if how_many_eaten == how_many_left {
"polishes off".to_owned()
} else {
format!(
"eats {} bite{} from",
how_many_eaten,
if how_many_eaten == 1 { "" } else { "s" }
)
}),
&item.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
if let Some(urges) = ctx.item.urges.as_mut() {
urges.hunger.last_value = urges.hunger.value;
urges.hunger.value = (urges.hunger.value as i16
+ (how_many_eaten as i16) * eat_data.hunger_impact)
.clamp(0, 10000) as u16;
urges.thirst.last_value = urges.thirst.value;
urges.thirst.value = (urges.thirst.value as i16
+ (how_many_eaten as i16) * eat_data.thirst_impact)
.clamp(0, 10000) as u16;
}
hunger_changed(&ctx.trans, &ctx.item).await?;
thirst_changed(&ctx.trans, &ctx.item).await?;
if item.charges <= (how_many_eaten as u8) {
ctx.trans.delete_item("possession", &item_id).await?;
} else {
let mut item_mut = (*item).clone();
item_mut.charges -= how_many_eaten as u8;
ctx.trans.save_item_model(&item_mut).await?;
}
Ok(())
}
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
mut remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
let mut eat_limit = Some(1);
if remaining == "all" || remaining.starts_with("all ") {
remaining = remaining[3..].trim();
eat_limit = None;
} else if let (Some(n), remaining2) = parse_count(remaining) {
eat_limit = Some(n);
remaining = remaining2;
}
let targets = search_items_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
item_type_only: Some("possession"),
limit: eat_limit.unwrap_or(100),
..ItemSearchParams::base(&player_item, &remaining)
},
)
.await?;
if player_item.death_data.is_some() {
user_error(
"You try to eat it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let mut player_item_mut = (*player_item).clone();
for target in targets {
if target.item_type != "possession" {
user_error("You can't eat that!".to_owned())?;
}
target
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
.and_then(|pd| pd.eat_data.as_ref())
.ok_or_else(|| UserError("You can't eat that!".to_owned()))?;
queue_command(
ctx,
&mut player_item_mut,
&QueueCommand::Eat {
possession_id: target.item_code.clone(),
},
)
.await?;
}
ctx.trans.save_item_model(&player_item_mut).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,37 @@
use crate::{models::effect::EffectType, services::combat::switch_to_feint};
use super::{get_player_item_or_fail, user_error, UResult, UserVerb, UserVerbRef, VerbContext};
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error("Feint while dead? You can't even do a regular attack.".to_owned())?;
}
if player_item
.active_effects
.iter()
.any(|v| v.0 == EffectType::Stunned)
{
user_error(
"You stay still like a stunned mullet, unable to gain the composure to feint."
.to_owned(),
)?;
}
switch_to_feint(ctx, &player_item).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,315 @@
use super::{
get_player_item_or_fail, search_item_for_user, user_error, ItemSearchParams, UResult, UserVerb,
UserVerbRef, VerbContext,
};
use crate::{
models::item::{Item, LiquidDetails, LiquidType},
regular_tasks::queued_command::{
queue_command_and_save, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::{capacity::recalculate_container_weight_mut, comms::broadcast_to_room},
};
use ansi::ansi;
use async_trait::async_trait;
use std::collections::{btree_map::Entry, BTreeMap};
use std::time;
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error(
"You try to fill it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let (from_item_type, from_item_code, to_item_type, to_item_code) = match ctx.command {
QueueCommand::Fill {
from_item_type,
from_item_code,
to_item_type,
to_item_code,
} => (from_item_type, from_item_code, to_item_type, to_item_code),
_ => user_error("Unexpected command".to_owned())?,
};
let from_item = match ctx
.trans
.find_item_by_type_code(&from_item_type, &from_item_code)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
let to_item = match ctx
.trans
.find_item_by_type_code(&to_item_type, &to_item_code)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if to_item.location != ctx.item.location && to_item.location != ctx.item.refstr() {
user_error(format!(
"You try to fill {} but realise you no longer have it",
to_item.display_for_sentence(1, false)
))?
}
if from_item.location != ctx.item.location && from_item.location != ctx.item.refstr() {
user_error(format!(
"You try to fill from {} but realise you no longer have it",
to_item.display_for_sentence(1, false)
))?
}
let msg = format!(
"{} prepares to fill {} from {}\n",
&ctx.item.display_for_sentence(1, true),
&to_item.display_for_sentence(1, false),
&from_item.display_for_sentence(1, false),
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error(
"You try to fill it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let (from_item_type, from_item_code, to_item_type, to_item_code) = match ctx.command {
QueueCommand::Fill {
from_item_type,
from_item_code,
to_item_type,
to_item_code,
} => (from_item_type, from_item_code, to_item_type, to_item_code),
_ => user_error("Unexpected command".to_owned())?,
};
let from_item = match ctx
.trans
.find_item_by_type_code(&from_item_type, &from_item_code)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
let to_item = match ctx
.trans
.find_item_by_type_code(&to_item_type, &to_item_code)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if to_item.location != ctx.item.location && to_item.location != ctx.item.refstr() {
user_error(format!(
"You try to fill {} but realise you no longer have it",
to_item.display_for_sentence(1, false)
))?
}
if from_item.location != ctx.item.location && from_item.location != ctx.item.refstr() {
user_error(format!(
"You try to fill from {} but realise you no longer have it",
to_item.display_for_sentence(1, false)
))?
}
let from_liquid_details = match from_item.liquid_details.as_ref() {
None => user_error(format!(
"{} appears to be empty.",
from_item.display_for_sentence(1, true)
))?,
Some(v) => v,
};
let available_vol = from_liquid_details
.contents
.iter()
.map(|r| r.1.clone())
.sum::<u64>();
if available_vol == 0 {
user_error(format!(
"{} appears to be empty.",
from_item.display_for_sentence(1, true)
))?
}
let into_liqdata = match to_item
.static_data()
.and_then(|pd| pd.liquid_container_data.as_ref())
{
None => user_error(format!(
"You can't find a way to fill {}.",
to_item.display_for_sentence(1, false)
))?,
Some(v) => v,
};
if let Some(allowed) = &into_liqdata.allowed_contents {
for (liq_type, _) in &from_liquid_details.contents {
if !allowed.contains(&liq_type) {
user_error(format!(
"You don't think putting {} into {} is a good idea.",
liq_type.display(),
to_item.display_for_sentence(1, false)
))?;
}
}
}
let capacity_remaining = into_liqdata.capacity
- to_item
.liquid_details
.as_ref()
.map(|ld| ld.contents.iter().map(|c| c.1.clone()).sum::<u64>())
.unwrap_or(0);
let actually_transferred = available_vol.min(capacity_remaining);
if actually_transferred == 0 {
user_error(format!(
"You don't think you can get any more into {}.",
to_item.display_for_sentence(1, false)
))?;
}
let transfer_frac = (actually_transferred as f64) / (available_vol as f64);
let mut remaining_total = actually_transferred;
let transfer_volumes: BTreeMap<LiquidType, u64> = from_liquid_details
.contents
.iter()
.flat_map(|(liqtype, vol)| {
let move_vol = (((*vol as f64) * transfer_frac).ceil() as u64).min(remaining_total);
remaining_total -= move_vol;
if move_vol > 0 {
Some((liqtype.clone(), move_vol))
} else {
None
}
})
.collect();
let mut to_item_mut: Item = (*to_item).clone();
match to_item_mut.liquid_details.as_mut() {
None => {
to_item_mut.liquid_details = Some(LiquidDetails {
contents: transfer_volumes.clone(),
})
}
Some(ld) => {
for (liq, vol) in &transfer_volumes {
ld.contents
.entry(liq.clone())
.and_modify(|v| {
*v += *vol;
})
.or_insert(vol.clone());
}
}
}
let mut from_item_mut: Item = (*from_item).clone();
if let Some(ld) = from_item_mut.liquid_details.as_mut() {
for (liq, vol) in &transfer_volumes {
match ld.contents.entry(liq.clone()) {
Entry::Vacant(_) => {}
Entry::Occupied(mut ent) => {
if ent.get() <= vol {
ent.remove();
} else {
*(ent.get_mut()) -= *vol;
}
}
}
}
}
recalculate_container_weight_mut(&ctx.trans, &mut from_item_mut).await?;
recalculate_container_weight_mut(&ctx.trans, &mut to_item_mut).await?;
ctx.trans.save_item_model(&from_item_mut).await?;
ctx.trans.save_item_model(&to_item_mut).await?;
let msg = format!(
"{} fills {} from {}\n",
&ctx.item.display_for_sentence(1, true),
&to_item.display_for_sentence(1, false),
&from_item.display_for_sentence(1, false),
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
Ok(())
}
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
let (to_str, from_str) = match remaining.split_once(" from ") {
None => user_error(
ansi!("Try <bold>fill<reset> container <bold>from<reset> container.").to_owned(),
)?,
Some((to_str, from_str)) => (to_str.trim(), from_str.trim()),
};
let to_target = search_item_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
include_loc_contents: true,
..ItemSearchParams::base(&player_item, to_str)
},
)
.await?;
let from_target = search_item_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
include_loc_contents: true,
..ItemSearchParams::base(&player_item, from_str)
},
)
.await?;
if player_item.death_data.is_some() {
user_error(
"You try to fill it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
if from_target.item_type != "possession" && from_target.item_type != "fixed_item" {
user_error("You can't fill from that!".to_owned())?;
}
if to_target.item_type != "possession" && to_target.item_type != "fixed_item" {
user_error("You can't fill that!".to_owned())?;
}
if to_target.item_type == from_target.item_type
&& to_target.item_code == from_target.item_code
{
user_error(
"You can't figure out how to fill something from itself - a shame!".to_owned(),
)?;
}
queue_command_and_save(
ctx,
&player_item,
&QueueCommand::Fill {
from_item_type: from_target.item_type.clone(),
from_item_code: from_target.item_code.clone(),
to_item_type: to_target.item_type.clone(),
to_item_code: to_target.item_code.clone(),
},
)
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,70 @@
use super::{get_player_item_or_fail, UResult, UserError, UserVerb, UserVerbRef, VerbContext};
use crate::{
models::item::{Item, ItemSpecialData},
static_content::npc::npc_by_code,
};
use ansi::ansi;
use async_trait::async_trait;
use std::sync::Arc;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let npc_name = remaining.trim().to_lowercase();
let player_item = get_player_item_or_fail(ctx).await?;
let mut match_staff: Vec<Arc<Item>> = ctx
.trans
.find_staff_by_hirer(&player_item.item_code)
.await?
.into_iter()
.filter(|it| {
it.display.to_lowercase().starts_with(&npc_name)
|| it
.aliases
.iter()
.any(|al| al.to_lowercase().starts_with(&npc_name))
})
.collect();
match_staff.sort_by_key(|it| (it.display.len() as i64 - npc_name.len() as i64).abs());
let npc: Arc<Item> = match_staff.first().ok_or_else(|| UserError(ansi!("You don't have any matching employees. Try <bold>hire<reset> to list your staff.").to_owned()))?.clone();
let hire_dat = npc_by_code()
.get(npc.item_code.as_str())
.as_ref()
.and_then(|npci| npci.hire_data.as_ref())
.ok_or_else(|| {
UserError(
"Sorry, I've forgotten how to fire them! Ask the game staff for help."
.to_owned(),
)
})?;
ctx.trans
.queue_for_session(
&ctx.session,
Some(&format!(
"A hushed silence falls over the room as you fire {}.\n",
npc.display_for_sentence(1, false),
)),
)
.await?;
let mut npc_mut = (*npc).clone();
hire_dat
.handler
.fire_handler(&ctx.trans, &player_item, &mut npc_mut)
.await?;
npc_mut.special_data = Some(ItemSpecialData::HireData { hired_by: None });
ctx.trans.save_item_model(&npc_mut).await?;
ctx.trans.delete_task("ChargeWages", &npc.item_code).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,324 @@
use super::{
get_player_item_or_fail, search_item_for_user, user_error, CommandHandlingError, DResult,
UResult, UserError, UserVerb, UserVerbRef, VerbContext,
};
#[double]
use crate::db::DBTrans;
use crate::{
db::ItemSearchParams,
models::item::{FollowData, FollowState, Item},
regular_tasks::queued_command::{queue_command_for_npc, MovementSource, QueueCommand},
static_content::room::Direction,
};
use async_trait::async_trait;
use mockall_double::double;
use std::mem;
pub async fn propagate_move_to_followers(
trans: &DBTrans,
mover: &Item,
direction: &Direction,
source: &MovementSource,
) -> DResult<()> {
let (event_id, chain) = match source {
MovementSource::Command { event_id } => (event_id, vec![]),
MovementSource::Follow { chain, origin_uuid } => (origin_uuid, chain.clone()),
MovementSource::Internal { .. } => return Ok(()), // Not to be propagated.
};
let mut new_chain = chain.clone();
new_chain.push(mover.refstr());
let new_movement = QueueCommand::Movement {
direction: direction.clone(),
source: MovementSource::Follow {
origin_uuid: event_id.clone(),
chain: new_chain,
},
};
for follower in trans.find_followers_by_leader(&mover.refstr()).await? {
let mut follower_mut = (*follower).clone();
if !chain.contains(&follower.refstr()) {
if let Some(&FollowData {
state: FollowState::IfSameRoom,
ref follow_whom,
}) = follower.following.as_ref()
{
if follower.location != mover.location {
continue;
}
follower_mut.following = Some(FollowData {
state: FollowState::Active,
follow_whom: follow_whom.clone(),
});
}
// We use the NPC variant since adding [queued] will be confusing.
match queue_command_for_npc(&trans, &mut follower_mut, &new_movement).await {
Err(e) if e.to_string().starts_with("Can't queue more") => {
suspend_follow_for_independent_move(&mut follower_mut);
}
Err(e) => return Err(e),
_ => {}
}
trans.save_item_model(&follower_mut).await?;
}
}
Ok(())
}
pub async fn update_follow_for_failed_movement(
trans: &DBTrans,
player: &mut Item,
source: &MovementSource,
) -> DResult<()> {
// Failing is the same as an independent movement back.
suspend_follow_for_independent_move(player);
let event_id = source.event_id();
for follower in trans.find_followers_by_leader(&player.refstr()).await? {
if let Some(QueueCommand::Movement {
source: MovementSource::Follow { origin_uuid, chain },
..
}) = follower.queue.front().as_ref()
{
if origin_uuid == event_id && chain.last() == Some(&player.refstr()) {
// The follower has already started moving (and their followers may indeed have
// followed them in turn). So we treat the move the follower made
// but the leader failed as an independent move.
let mut follower_mut = (*follower).clone();
suspend_follow_for_independent_move(&mut follower_mut);
trans.save_item_model(&follower_mut).await?;
continue;
}
}
// Otherwise, it's not too late, so just cancel it from the queue.
let mut follower_mut = (*follower).clone();
follower_mut.queue.retain(|qit| match qit {
QueueCommand::Movement {
source: MovementSource::Follow { origin_uuid, chain },
..
} => !(origin_uuid == event_id && chain.last() == Some(&player.refstr())),
_ => true,
});
if follower_mut.queue != follower.queue {
trans.save_item_model(&follower_mut).await?;
}
}
Ok(())
}
pub fn suspend_follow_for_independent_move(player: &mut Item) {
if let Some(following) = player.following.as_mut() {
following.state = FollowState::IfSameRoom;
}
}
// Caller has to save follower.
pub async fn stop_following(trans: &DBTrans, follower: &mut Item) -> UResult<()> {
let old_following = mem::replace(&mut follower.following, None)
.ok_or_else(|| UserError("You're not following anyone.".to_owned()))?;
if let Some((follow_type, follow_code)) = old_following.follow_whom.split_once("/") {
if let Some(old_item) = trans
.find_item_by_type_code(&follow_type, &follow_code)
.await?
{
if follower.item_type == "player" {
if let Some((session, _session_dat)) =
trans.find_session_for_player(&follower.item_code).await?
{
trans
.queue_for_session(
&session,
Some(&format!(
"You are no longer following {}.\n",
old_item.display_for_sentence(1, false)
)),
)
.await?;
}
}
}
}
Ok(())
}
pub async fn cancel_follow_by_leader(trans: &DBTrans, leader: &str) -> DResult<()> {
for follower in trans.find_followers_by_leader(leader).await? {
if let Some(player_item) = trans
.find_item_by_type_code(&follower.item_type, &follower.item_code)
.await?
{
let mut player_item_mut = (*player_item).clone();
match stop_following(trans, &mut player_item_mut).await {
Ok(_) => {}
Err(CommandHandlingError::UserError(_)) => {}
Err(CommandHandlingError::SystemError(e)) => Err(e)?,
}
trans.save_item_model(&player_item_mut).await?;
}
}
Ok(())
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
verb: &str,
remaining: &str,
) -> UResult<()> {
let mut player_item = (*(get_player_item_or_fail(ctx).await?)).clone();
if verb == "follow" {
let follow_whom = search_item_for_user(
ctx,
&ItemSearchParams {
include_loc_contents: true,
..ItemSearchParams::base(&player_item, remaining.trim())
},
)
.await?;
if follow_whom.item_type != "player" && follow_whom.item_type != "npc" {
user_error("Only characters (player / NPC) can be followed.".to_owned())?;
}
if follow_whom.item_type == "player" && follow_whom.item_code == player_item.item_code {
user_error("No point chasing your own tail!".to_owned())?;
}
if let Some(follow) = player_item.following.as_ref() {
if follow.follow_whom == follow_whom.refstr() {
user_error(format!(
"You're already following {}!",
follow_whom.pronouns.possessive
))?;
}
}
if ctx
.trans
.count_followers_by_leader(&follow_whom.refstr())
.await?
>= 20
{
user_error(format!(
"There's such a huge crowd following {}, there's not really room for more.",
&follow_whom.pronouns.object
))?;
}
if player_item.active_climb.is_some() {
user_error(
"You can't focus on following someone while you are climbing!".to_owned(),
)?;
}
player_item.queue.retain(|qit| match qit {
QueueCommand::Movement { .. } => false,
_ => true,
});
if let Some(QueueCommand::Movement { direction, source }) =
follow_whom.queue.front().as_ref()
{
let event_id = source.event_id();
// Safer to make a singleton chain, since follow is manual
// intervention. Even if the current movement came from player,
// still want to duplicate it, and don't want to duplicate
// player in chain.
let new_chain = vec![follow_whom.refstr()];
let new_movement = QueueCommand::Movement {
direction: direction.clone(),
source: MovementSource::Follow {
origin_uuid: event_id.clone(),
chain: new_chain,
},
};
// We use the NPC variant since adding [queued] will be confusing.
queue_command_for_npc(&ctx.trans, &mut player_item, &new_movement).await?;
}
player_item.following = Some(FollowData {
follow_whom: follow_whom.refstr(),
state: FollowState::Active,
});
ctx.trans
.queue_for_session(
&ctx.session,
Some(&format!(
"You are now following {}.\n",
&follow_whom.display_for_sentence(1, false)
)),
)
.await?;
ctx.trans.save_item_model(&player_item).await?;
} else {
stop_following(&ctx.trans, &mut player_item).await?;
ctx.trans.save_item_model(&player_item).await?;
}
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;
#[cfg(test)]
mod test {
use uuid::Uuid;
use super::*;
use crate::db::MockDBTrans;
use std::sync::Arc;
#[test]
fn propagating_to_overflowing_queue_works() {
let mut trans = MockDBTrans::default();
let leader: Item = Item {
item_type: "test".to_owned(),
item_code: "test1".to_owned(),
..Default::default()
};
let source = MovementSource::Command {
event_id: Uuid::parse_str("ed25224e-9925-4635-8e4f-e612376ef6e9").unwrap(),
};
let follower: Item = Item {
item_type: "test".to_owned(),
item_code: "test2".to_owned(),
queue: std::iter::repeat(QueueCommand::Movement {
direction: Direction::NORTH,
source: source.clone(),
})
.take(20)
.collect(),
following: Some(FollowData {
follow_whom: "test/test1".to_owned(),
state: FollowState::Active,
}),
..Default::default()
};
trans
.expect_find_followers_by_leader()
.times(1)
.withf(|m| m == "test/test1")
.returning(move |_| Ok(vec![Arc::new(follower.clone())]));
trans
.expect_save_item_model()
.times(1)
.withf(|i| {
&i.item_code == "test2"
&& i.queue.len() == 20
&& i.following
== Some(FollowData {
follow_whom: "test/test1".to_owned(),
state: FollowState::IfSameRoom,
})
})
.returning(|_| Ok(()));
assert!(tokio_test::block_on(propagate_move_to_followers(
&trans,
&leader,
&Direction::NORTH,
&source
))
.is_ok());
}
}

View File

@ -0,0 +1,113 @@
use super::{get_player_item_or_fail, UResult, UserError, UserVerb, UserVerbRef, VerbContext};
use crate::{
models::item::LocationActionType,
static_content::{
possession_type::{possession_data, DamageType},
species::species_info_map,
},
};
use ansi::ansi;
use async_trait::async_trait;
use std::collections::BTreeMap;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
let all_gear = ctx
.trans
.find_by_action_and_location(&player_item.refstr(), &LocationActionType::Worn)
.await?;
let mut msg = String::new();
msg.push_str(&format!(
ansi!(
"<bgblue><white><bold>| {:6} | {:25} | {:5} | {:5} | {:5} | {:5} | {:5} |<reset>\n"
),
"Part", "Clothing", "Beat", "Slash", "Prce", "Shock", "Bullt"
));
for body_part in &species_info_map()
.get(&player_item.species)
.ok_or_else(|| UserError("Species not found".to_owned()))?
.body_parts
{
let mut damage_ranges: BTreeMap<DamageType, (f64, f64)> = BTreeMap::new();
for damtyp in [
DamageType::Beat,
DamageType::Slash,
DamageType::Pierce,
DamageType::Shock,
DamageType::Bullet,
] {
damage_ranges.insert(damtyp, (0.0, 0.0));
}
let mut worn: String = String::new();
for item in &all_gear {
if let Some(wear_data) = item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
.and_then(|pd| pd.wear_data.as_ref())
{
if wear_data.covers_parts.contains(&body_part) {
if !worn.is_empty() {
worn.push_str(", ");
}
worn.push_str(&item.display);
for entry in damage_ranges.iter_mut() {
if let Some(soak_data) = wear_data.soaks.get(entry.0) {
let (old_min, old_max) = entry.1;
*entry.1 =
(*old_min + soak_data.min_soak, *old_max + soak_data.max_soak);
}
}
}
}
}
worn.truncate(25);
msg.push_str(&format!(
ansi!("| <bold>{:6}<reset> | {:25} | {:2}-{:2} | {:2}-{:2} | {:2}-{:2} | {:2}-{:2} | {:2}-{:2} |\n"),
body_part.display(player_item.sex.clone()),
&worn,
damage_ranges.get(&DamageType::Beat).map(|dt| dt.0).unwrap_or(0.0),
damage_ranges.get(&DamageType::Beat).map(|dt| dt.1).unwrap_or(0.0),
damage_ranges.get(&DamageType::Slash).map(|dt| dt.0).unwrap_or(0.0),
damage_ranges.get(&DamageType::Slash).map(|dt| dt.1).unwrap_or(0.0),
damage_ranges.get(&DamageType::Pierce).map(|dt| dt.0).unwrap_or(0.0),
damage_ranges.get(&DamageType::Pierce).map(|dt| dt.1).unwrap_or(0.0),
damage_ranges.get(&DamageType::Shock).map(|dt| dt.0).unwrap_or(0.0),
damage_ranges.get(&DamageType::Shock).map(|dt| dt.1).unwrap_or(0.0),
damage_ranges.get(&DamageType::Bullet).map(|dt| dt.0).unwrap_or(0.0),
damage_ranges.get(&DamageType::Bullet).map(|dt| dt.1).unwrap_or(0.0),
));
}
let mut total_dodge: f64 = 0.0;
for item in &all_gear {
if let Some(wear_data) = item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
.and_then(|pd| pd.wear_data.as_ref())
{
total_dodge += wear_data.dodge_penalty;
}
}
msg.push_str(&format!(
"Total dodge penalty from armour: {}\n",
total_dodge
));
ctx.trans.queue_for_session(ctx.session, Some(&msg)).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,343 @@
use super::{
get_player_item_or_fail, parsing::parse_count, search_item_for_user, search_items_for_user,
user_error, ItemSearchParams, UResult, UserError, UserVerb, UserVerbRef, VerbContext,
};
use crate::{
models::item::{Item, LocationActionType},
regular_tasks::queued_command::{
queue_command, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::{
capacity::{check_item_capacity, recalculate_container_weight, CapacityLevel},
comms::broadcast_to_room,
},
static_content::possession_type::{possession_data, ContainerFlag},
};
use async_trait::async_trait;
use std::time;
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error(
"You try to get it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
match ctx.command {
QueueCommand::Get { possession_id } => {
let item = match ctx
.trans
.find_item_by_type_code("possession", &possession_id)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != ctx.item.location {
user_error(format!(
"You try to get {} but realise it is no longer there",
item.display_for_sentence(1, false)
))?
}
let msg = format!(
"{} fumbles around trying to pick up {}\n",
&ctx.item.display_for_sentence(1, true),
&item.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
}
QueueCommand::GetFromContainer {
from_item_id,
get_possession_id,
} => {
let (from_item_type, from_item_code) = from_item_id
.split_once("/")
.ok_or_else(|| UserError("Bad from_item_id for get".to_owned()))?;
let container = ctx
.trans
.find_item_by_type_code(&from_item_type, &from_item_code)
.await?
.ok_or_else(|| UserError("Item to get from not found".to_owned()))?;
if container.location != ctx.item.location
&& container.location != ctx.item.refstr()
{
user_error(format!(
"You try to get something from {} but realise {} is no longer there",
container.display_for_sentence(1, false),
container.display_for_sentence(1, false),
))?
}
let item = ctx
.trans
.find_item_by_type_code("possession", &get_possession_id)
.await?
.ok_or_else(|| UserError("Item to get not found".to_owned()))?;
if item.location != container.refstr() {
user_error(format!(
"You try to get {} but realise it is no longer in {}",
item.display_for_sentence(1, false),
container.display_for_sentence(1, false),
))?
}
let msg = format!(
"{} fumbles around trying to get {} from {}.\n",
&ctx.item.display_for_sentence(1, true),
&item.display_for_sentence(1, false),
&container.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
}
_ => user_error("Unexpected command".to_owned())?,
};
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error(
"You try to get it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let (item, container_opt) = match ctx.command {
QueueCommand::Get { possession_id } => {
let item = match ctx
.trans
.find_item_by_type_code("possession", &possession_id)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != ctx.item.location {
user_error(format!(
"You try to get {} but realise it is no longer there",
&item.display_for_sentence(1, false)
))?
}
ctx.trans.delete_task("ChargeItem", &item.refstr()).await?;
let msg = format!(
"{} picks up {}\n",
&ctx.item.display_for_sentence(1, true),
&item.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
(item, None)
}
QueueCommand::GetFromContainer {
from_item_id,
get_possession_id,
} => {
let (from_item_type, from_item_code) = from_item_id
.split_once("/")
.ok_or_else(|| UserError("Bad from_item_id for get".to_owned()))?;
let container = ctx
.trans
.find_item_by_type_code(from_item_type, from_item_code)
.await?
.ok_or_else(|| UserError("Item to get from not found".to_owned()))?;
if container.location != ctx.item.location
&& container.location != ctx.item.refstr()
{
user_error(format!(
"You try to get something from {} but realise {} is no longer there",
container.display_for_sentence(1, false),
container.display_for_sentence(1, false),
))?
}
let item = ctx
.trans
.find_item_by_type_code("possession", &get_possession_id)
.await?
.ok_or_else(|| UserError("Item to get not found".to_owned()))?;
if item.location != container.refstr() {
user_error(format!(
"You try to get {} but realise it is no longer in {}",
item.display_for_sentence(1, false),
container.display_for_sentence(1, false),
))?
}
let msg = format!(
"{} gets {} from {}.\n",
&ctx.item.display_for_sentence(1, true),
&item.display_for_sentence(1, false),
&container.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
(item, Some(container))
}
_ => user_error("Unexpected command".to_owned())?,
};
let possession_dat = match item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(&pt))
{
None => {
user_error("That item no longer exists in the game so can't be handled".to_owned())?
}
Some(pd) => pd,
};
match check_item_capacity(ctx.trans, &ctx.item, possession_dat.weight).await? {
CapacityLevel::AboveItemLimit => {
user_error("You just can't hold that many things!".to_owned())?
}
CapacityLevel::OverBurdened => user_error(format!(
"Fuck! You can't get {} because it is too heavy!",
&item.display_for_sentence(1, false)
))?,
_ => (),
}
let mut item_mut = (*item).clone();
item_mut.location = ctx.item.refstr();
item_mut.action_type = LocationActionType::Normal;
ctx.trans.save_item_model(&item_mut).await?;
if let Some(container) = container_opt {
if container
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
.and_then(|pd| pd.container_data.as_ref())
.map_or(false, |cd| {
cd.container_flags.contains(&ContainerFlag::DestroyOnEmpty)
})
&& ctx
.trans
.get_location_stats(&container.refstr())
.await?
.total_count
== 0
{
ctx.trans
.delete_item(&container.item_type, &container.item_code)
.await?;
} else {
recalculate_container_weight(&ctx.trans, &container).await?;
}
}
Ok(())
}
}
fn check_get_from_allowed(from_what: &Item) -> UResult<()> {
if from_what.item_type == "player" || from_what.item_type == "npc" {
if from_what.death_data.is_none() {
user_error(format!(
"You don't think {} would let you just take it like that!",
&from_what.pronouns.subject
))?;
}
return Ok(());
}
if !vec!["possession", "static_item"].contains(&from_what.item_type.as_str()) {
user_error("You can't get from that!".to_owned())?;
}
Ok(())
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
mut remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
let mut get_limit = Some(1);
if remaining == "all" || remaining.starts_with("all ") {
remaining = remaining[3..].trim();
get_limit = None;
} else if let (Some(n), remaining2) = parse_count(remaining) {
get_limit = Some(n);
remaining = remaining2;
}
let (search_what, for_what, include_contents, include_loc_contents) =
match remaining.split_once(" from ") {
None => (player_item.clone(), remaining, false, true),
Some((item_str_raw, container_str_raw)) => {
let container = search_item_for_user(
ctx,
&ItemSearchParams {
include_loc_contents: true,
include_contents: true,
..ItemSearchParams::base(&player_item, container_str_raw.trim())
},
)
.await?;
(container, item_str_raw.trim(), true, false)
}
};
let targets = search_items_for_user(
ctx,
&ItemSearchParams {
include_loc_contents,
include_contents,
item_type_only: Some("possession"),
limit: get_limit.unwrap_or(100),
..ItemSearchParams::base(&search_what, for_what)
},
)
.await?;
if player_item.death_data.is_some() {
user_error(
"You try to get it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
if include_contents {
check_get_from_allowed(&search_what)?;
}
let mut did_anything: bool = false;
let mut player_item_mut = (*player_item).clone();
for target in targets
.iter()
.filter(|t| t.action_type.is_visible_in_look())
{
if target.item_type != "possession" {
user_error("You can't get that!".to_owned())?;
}
did_anything = true;
if include_loc_contents {
queue_command(
ctx,
&mut player_item_mut,
&QueueCommand::Get {
possession_id: target.item_code.clone(),
},
)
.await?;
} else {
queue_command(
ctx,
&mut player_item_mut,
&QueueCommand::GetFromContainer {
from_item_id: search_what.refstr(),
get_possession_id: target.item_code.clone(),
},
)
.await?;
}
}
if !did_anything {
user_error("I didn't find anything matching.".to_owned())?;
} else {
ctx.trans.save_item_model(&player_item_mut).await?;
}
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,96 @@
use crate::{
models::user::{wristpad_hack_data, xp_to_hack_slots},
services::skills::calculate_total_stats_skills_for_user,
static_content::room::room_map_by_code,
};
use super::{
get_player_item_or_fail, user_error, UResult, UserError, UserVerb, UserVerbRef, VerbContext,
};
use ansi::ansi;
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
let (loc_type, loc_code) = player_item
.location
.split_once("/")
.ok_or_else(|| UserError("Your location is invalid".to_owned()))?;
if loc_type != "room" {
user_error("You can't find a hacking unit here.".to_owned())?;
}
let room = room_map_by_code()
.get(&loc_code)
.ok_or_else(|| UserError("Your location no longer exists!".to_owned()))?;
let allowed_hack = room
.wristpad_hack_allowed
.as_ref()
.ok_or_else(|| UserError("You can't find a hacking unit here.".to_owned()))?;
let hack_data = wristpad_hack_data()
.get(allowed_hack)
.ok_or_else(|| UserError("The hacking unit is currently broken.".to_owned()))?;
if hack_data.name.to_lowercase() != remaining.trim() {
user_error(format!(
ansi!("The equipment here only allows you to <bold>hack {}<reset>"),
&hack_data.name
))?;
}
let user = ctx
.user_dat
.as_mut()
.ok_or(UserError("Please log in first".to_owned()))?;
let slots_available = xp_to_hack_slots(player_item.total_xp) as usize;
let slots_used = user.wristpad_hacks.len();
if slots_used >= slots_available {
user_error(format!(
"Your wristpad crashes and reboots, flashing up an error that \
there was no space to install the hack. [You only have {} slots \
total on your wristpad to install hacks - try getting some \
more experience to earn more]",
slots_available
))?;
}
if user.wristpad_hacks.contains(&allowed_hack) {
user_error(
"Your wristpad crashes and reboots, flashing up an error that \
the same hack was already found on the device."
.to_owned(),
)?;
}
user.wristpad_hacks.push(allowed_hack.clone());
ctx.trans.save_user_model(&user).await?;
let mut player_mut = (*player_item).clone();
calculate_total_stats_skills_for_user(&mut player_mut, user);
ctx.trans.save_item_model(&player_mut).await?;
ctx.trans
.queue_for_session(
&ctx.session,
Some(&format!(
"Your wristpad beeps and reboots. You notice new icon on \
it indicating the {} hack has been applied succesfully!\n",
hack_data.name
)),
)
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -1,77 +1,102 @@
use super::{ use std::collections::BTreeMap;
VerbContext, UserVerb, UserVerbRef, UResult,
CommandHandlingError::UserError use super::{CommandHandlingError::UserError, UResult, UserVerb, UserVerbRef, VerbContext};
}; use ansi_markup::parse_ansi_markup;
use async_trait::async_trait; use async_trait::async_trait;
use ansi::ansi; use once_cell::sync::OnceCell;
use phf::phf_map; use serde_yaml::from_str as from_yaml_str;
static ALWAYS_HELP_PAGES: phf::Map<&'static str, &'static str> = phf_map! { fn load_help_yaml(input: &str) -> BTreeMap<String, String> {
"<topicname>" => let mut map: BTreeMap<String, String> = from_yaml_str(input).unwrap();
ansi!("You are supposed to replace <lt>topicname> with the topic you want \ for val in map.values_mut() {
to learn about. Example:\r\n\ *val = parse_ansi_markup(val).unwrap();
\t<bold>help register<reset> will tell you about the register command.") }
}; map
}
static UNREGISTERED_HELP_PAGES: phf::Map<&'static str, &'static str> = phf_map! { fn always_help_pages() -> &'static BTreeMap<String, String> {
"" => static CELL: OnceCell<BTreeMap<String, String>> = OnceCell::new();
ansi!("Type <bold>help <lt>topicname><reset> to learn about a topic. Most \
commands can be used as a topicname.\r\n\
Topics of interest to unregistered users:\r\n\
\t<bold>register<reset>\tLearn about the <bold>register<reset> command.\r\n\
\t<bold>login<reset>\tLearn how to log in as an existing user.\r\n"),
"register" =>
ansi!("Registers a new user. You are allowed at most 5 at once.\r\n\
\t<bold>register <lt>username> <lt>password> <lt>email><reset>\r\n\
Email will be used to check you don't have too many accounts and \
in case you need to reset your password."),
"login" =>
ansi!("Logs in as an existing user.\r\n\
\t<bold>login <lt>username> <lt>password<reset>")
};
static REGISTERED_HELP_PAGES: phf::Map<&'static str, &'static str> = phf_map! { CELL.get_or_init(|| load_help_yaml(include_str!("help/always.yaml")))
"" => }
ansi!("Type <bold>help <lt>topicname><reset> to learn about a topic. Most \
commands can be used as a topicname.\r\n\
Topics of interest:\r\n\
\t<bold>newbie<reset>\tLearn the absolute basics."),
"newbie" =>
ansi!("So you've just landed in BlastMud, and want to know how to get started?\r\n\
As we develop the game, this will eventually have some useful information for you!"),
};
static EXPLICIT_HELP_PAGES: phf::Map<&'static str, &'static str> = phf_map! { fn registered_help_pages() -> &'static BTreeMap<String, String> {
"fuck" => static CELL: OnceCell<BTreeMap<String, String>> = OnceCell::new();
ansi!("Type <bold>fuck <lt>name><reset> to fuck someone. It only works if \
they have consented.") CELL.get_or_init(|| load_help_yaml(include_str!("help/registered.yaml")))
}; }
fn unregistered_help_pages() -> &'static BTreeMap<String, String> {
static CELL: OnceCell<BTreeMap<String, String>> = OnceCell::new();
CELL.get_or_init(|| load_help_yaml(include_str!("help/unregistered.yaml")))
}
pub struct Verb; pub struct Verb;
#[async_trait] #[async_trait]
impl UserVerb for Verb { impl UserVerb for Verb {
async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let mut help = None; let mut help = None;
let is_unregistered = match ctx.user_dat { let is_unregistered = match ctx.user_dat {
None => true, None => true,
Some(user_dat) => !user_dat.terms.terms_complete Some(user_dat) => !user_dat.terms.terms_complete,
}; };
let remaining = remaining.trim();
if is_unregistered { if is_unregistered {
help = help.or_else(|| UNREGISTERED_HELP_PAGES.get(remaining)); help = help.or_else(|| unregistered_help_pages().get(remaining));
} else { } else {
help = help.or_else(|| REGISTERED_HELP_PAGES.get(remaining)); help = help.or_else(|| registered_help_pages().get(remaining));
if !ctx.session_dat.less_explicit_mode {
help = help.or_else(|| EXPLICIT_HELP_PAGES.get(remaining))
}
} }
help = help.or_else(|| ALWAYS_HELP_PAGES.get(remaining)); help = help.or_else(|| always_help_pages().get(remaining).map(|v| v));
let help_final = help.ok_or( let help_final = help.ok_or(UserError("No help available on that".to_string()))?;
UserError("No help available on that".to_string()))?; ctx.trans
ctx.trans.queue_for_session(ctx.session, .queue_for_session(ctx.session, Some(&(help_final.clone() + "\n")))
Some(&(help_final.to_string() + "\r\n")) .await?;
).await?;
Ok(()) Ok(())
} }
} }
static VERB_INT: Verb = Verb; static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef; pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use crate::message_handler::user_commands::registered_commands;
use super::*;
#[test]
fn registered_help_ok() {
registered_help_pages();
}
#[test]
fn unregistered_help_ok() {
unregistered_help_pages();
}
#[test]
fn always_help_ok() {
always_help_pages();
}
#[test]
fn all_registered_user_commands_documented() {
let help_keys: BTreeSet<String> =
registered_help_pages().keys().map(|k| k.clone()).collect();
let cmds: BTreeSet<String> = registered_commands()
.keys()
.map(|k| k.to_string())
.collect();
assert_eq!(
cmds.difference(&help_keys).collect::<BTreeSet<&String>>(),
BTreeSet::<&String>::new()
);
}
}

View File

@ -0,0 +1,3 @@
"<topicname>": |-
You are supposed to replace <lt>topicname> with the topic you want to learn about. Example:
<bold>help register<reset> will tell you about the register command.

View File

@ -0,0 +1,405 @@
"": |-
Type <bold>help <lt>topicname><reset> to learn about a topic. Most commands can be used as a topicname.
Topics of interest:
<bold>newbie<reset> Learn the absolute basics.
<bold>movement<reset> Commands for moving around.
<bold>possessions<reset> Commands for dealing with possessions / weapons.
<bold>talk<reset> Find out how to talk in the game.
<bold>combat<reset> Learn how to fight.
<bold>information<reset> Learn how to find out about the world and your character.
newbie: |-
So you've just landed in Blastmud, and want to know how to get started?
You control your character, and can tell your character to move around the
world, and see things through their eyes.
The world (yes, even outside!) is divided up into rooms, and each room has
exits that you are allowed to take, normally called north, south, east, west,
northeast, northwest, southeast, southwest, up and down (sometimes you can also go in).
Try <bold>look<reset> (or <bold>l<reset>) to look at the current room. It will
also show you all the exits you can take, and a little ASCII-art map showing
the local layout. <bold>lmap<reset> shows you a more detailed map showing the
directions you can move in. You can also look at objects or players with
<bold>l<reset> followed by the name of the object (shortening is okay).
Once you know what direction to move, you can type the direction, using either
the full name of the direction (e.g. <bold>northeast<reset> or <bold>south<reset>,
or a short form (<bold>n<reset>, <bold>e<reset>, <bold>s<reset>, <bold>w<reset>, <bold>ne<reset>, <bold>nw<reset>, <bold>se<reset>, <bold>sw<reset>, <bold>up<reset>, <bold>down<reset>).
Also try the map commands - <bold>lmap<reset> for a local map including exits, and <bold>gmap<reset> for a giant
map to let you see the broader context.
movement: &movement |-
Once you know what direction to move, you can type the direction, using either
the full name of the direction (e.g. <bold>northeast<reset> or <bold>south<reset>,
or a short form (<bold>n<reset>, <bold>e<reset>, <bold>s<reset>, <bold>w<reset>, <bold>ne<reset>, <bold>nw<reset>, <bold>se<reset>, <bold>sw<reset>, <bold>up<reset>, <bold>down<reset>).
n: *movement
north: *movement
ne: *movement
northeast: *movement
e: *movement
east: *movement
se: *movement
southeast: *movement
s: *movement
south: *movement
sw: *movement
southwest: *movement
w: *movement
west: *movement
nw: *movement
northwest: *movement
up: *movement
down: *movement
in: *movement
look: &look >-
Try <bold>look<reset>, <bold>l<reset>, <bold>examine<reset> or <bold>ex<reset> to look at the current room.
Follow it with the name of an exit to look at an adjacent room.
Or name an object or player in the room to look at that.
You can also use <bold>look at<reset> thing <bold>for sale<reset> while in a shop to inspect the wares before
you buy.
l: *look
ex: *look
read: *look
examine: *look
describe: Use <bold>describe me as<reset> description to set how you appear when people look at you.
who: Use <bold>who<reset> to see what players are online.
combat: &combat >-
Type <bold>kill<reset> character or <bold>k<reset> character to attack a character.
If their health points hits zero, they die. If your health points hit zero, you die.
Note that characters can reclone so death isn't fully permanent (see <bold>help death<reset>).
Combat depends on your ability to dodge the enemy's attacks (based on your dodge skill),
your armour's ability to soak their attacks, and the skill in the weapon you are wielding
- and of course the same factors for your opponents.
You can only attack one character at a time, but more than one can gang up on you!
When you fight, you might notice you improve at your skills (see <bold>help skills<reset>),
and also gain more experience. You gain more skills using a weapon matched to your current
experience level, and more experience fighting harder opponents - but if you take on someone
too tough, you might die and lose experience recloning!
Remember, you can always (at least while you are not dead) try to run away from combat by just
by using movement commands (see <bold>help movement<reset> - but if your dodge skill isn't high
enough, it might fail.
k: *combat
kill: *combat
attack: *combat
pow: &pow >-
Type <bold>powerattack<reset> or <bold>pow<reset> while you are in combat to make your
next move a powerattack. For weapons where it is possible, a powerattack takes longer
to do, but hits harder, and has a higher chance of inflicting critical damage on the
enemy.
power: *pow
powerattack: *pow
feint: >-
Use <bold>feint<reset> while you are in combat to make your next move a feint. This
will pit your intelligence against your opponent, and confuse them if you win. But
beware, if they win, you might end up confused!
death: >-
In Blastmud, you can die if your health points reach zero. While you are dead, you can't
move or talk! Unless someone quickly comes and uses a defibrilator on you, your current
body is gone. Luckily, thanks to residual Gazos-Murlison technology that survived the
fall of the empire, you can still re-clone into a new body. However, this has some
downsides: you lose a bit of your experience in the process, and anything you had on your
person stays with the corpse of your old body! So it is certainly worth trying not to die!
skills: >-
Blastmud has lots of different skills that you can learn. There are two different
measures of a skill. Total skill is how good you are in absolute terms - you get some skill from your stats,
and some from your raws. Raw skill is how much you have improved by learning the skill as you
play the game. Your raw skill caps out at 15 per skill, and adds on to the contribution from
your stats, and any temporary buffs or debuffs caused by your character's state.
information: &info |-
Commands to examine the world:
<bold>look<reset> (or <bold>l<reset>) to look around - follow it with an exit or
a character / item name for detail on that.
<bold>lmap<reset> - get a map showing exits and places.
<bold>gmap<reset> - see a larger (giant) map.
lmap: *info
lm: *info
gmap: *info
gm: *info
talk: &talk |-
Use:
<bold>'<reset>message to send message to the room.
<bold>-<reset>user message to whisper to someone.
<bold>p<reset> user message to page someone on your wristpad.
<bold>reply<reset> message (or <bold>rep<reset> message) to page back the last person to page you.
say: *talk
whisper: *talk
page: *talk
reply: *talk
"'": *talk
"-": *talk
rep: *talk
repl: *talk
tell: *talk
p: *talk
pg: *talk
possessions: &possessions |-
Use:
<bold>get<reset> item to pick something up from the ground.
<bold>drop<reset> item to drop something from your inventory.
<bold>inv<reset> to see what you are holding.
<bold>buy<reset> item to buy something from the shop you are in.
<bold>sell<reset> item to sell something to the shop you are in.
<bold>list<reset> to see the price list for the stop.
<bold>wield<reset> to hold a weapon in your inventory for use when you attack.
<bold>gear<reset> to see what you are wearing, and how much protection it offers.
Hint: get and drop support an item name, but you can also prefix it with a number - e.g. <bold>get 2 whip<reset>. Instead of a number, you can use <bold>all<reset>. You can also omit the item name to match any possession, e.g. <bold>drop all<reset> will drop everything you have.
get: *possessions
drop: *possessions
i: *possessions
inv: *possessions
inventory: *possessions
buy: *possessions
sell: *possessions
list: *possessions
wield: *possessions
gear: *possessions
allow: &consent |-
<bold>allow<reset> is the corner-stone of Blastmud's consent system. Consents in Blastmud let you choose how you want to play with other players (it only affects players, not NPCs). There are 5 types of consent: <bold>fight<reset> (for hostile actions like attack or pick), <bold>medicine<reset> (for medical actions, including those that might crit fail and do harm), <bold>gifts<reset> (lets them give you things), <bold>visit<reset> (lets them on to a tile owned by you legally), and <bold>share<reset> (lets them local share knowledge with you, making both parties stronger).
To allow, as an individual, use the syntax <bold>allow <reset>type <bold>from <reset>player options
As a corp, use the syntax <bold>allow <reset>type <bold>against <reset>corpname <bold>by<reset> corpname options
Options can be blank to use defaults, or can be one or more of the following, separated by spaces:
<bold>for<reset> n <bold>minutes<reset> - replace n with a number. You can use hours, days, or weeks instead of minutes. This makes the consent expire. Fight expires after a week if you don't give a shorter period, and all other consent types have no expiry unless you specify one.
<bold>until death<reset> - makes the consent valid only until you next die.
<bold>allow private<reset> - makes the consent valid even in privately owned places. This is the default for anything except fight.
<bold>disallow private<reset> - the opposite of allow private.
<bold>in<reset> place - limits where fighting can happen to selected public places. You can use
<bold>here<reset>, or if you know the code, a place name. You can use this option more than once to allow any place, and if you don't use the option, it means anywhere (subject to allow private).
<bold>allow pick<reset> - fight only - include picking in your consent.
<bold>allow revoke<reset> - fight only - allows the player to revoke any time with disallow.
Consents for anything except fight take effect immediately to let the other player do the action.
Consents for fight take effect when the other player executes a reciprocal allow command.
Consents for anything except than fight can be revoked instantly with:
<bold>disallow<reset> action <bold>from<reset> player
Consent for fight can be revoked similarly if the consent used the <bold>allow revoke<reset> option.
Otherwise, attempting to revoke informs the other player, and it is revoked when they also issue a disallow command.
consent: *consent
disallow: *consent
corp: |-
<bold>corp<reset> is the entry point to commands related to corps. It supports the following subcommands:
<bold>corp hire<reset> username <bold>into<reset> corpname
Hires a player into your corp.
<bold>corp join<reset> corpname
Accepts an offer to hire you.
<bold>corp list<reset>
List the corps you belong to.
<bold>corp leave<reset> corpname
Resign from a corp.
<bold>corp fire<reset> username <bold>from<reset> corpname
Fires a player from your corp.
<bold>corp promote<reset> username <bold>in<reset> corpname <bold>to<reset> title <bold>privileges<reset> privileges
Promotes/demotes a player within your corp.
<bold>corp info<reset> corpname
Get information about a corp.
<bold>corp allow combat<reset>
Allow the corp to consent to combat on your behalf.
<bold>corp disallow combat<reset>
Don't allow the corp to consent to combat on your behalf.
<bold>corp config<reset> corpname setting
Configure corp settings.
<bold>corp sub<reset> type <bold>from<reset> corpname
Subscribe to corp broadcasts.
<bold>corp unsub<reset> type <bold>from<reset> corpname
Unsubscribe from corp broadcasts.
<bold>corp order<reset> corpname <bold>as<reset> number
Set the position of the corp in your list.
Help is available for many individual corp commands, e.g. try <bold>help corp hire<reset>
"corp hire": "<bold>corp hire<reset> username <bold>into<reset> corpname Hires a player into your corp."
"corp join": "<bold>corp join<reset> corpname Accepts an offer to hire you."
"corp list": "<bold>corp list<reset> List the corps you belong to."
"corp leave": &corp-leave "<bold>corp leave<reset> corpname Resign from a corp."
"corp resign": *corp-leave
"corp fire": "<bold>corp fire<reset> username <bold>from<reset> corpname Fires a player from your corp."
"corp promote": |-
<bold>corp promote<reset> username <bold>in<reset> corpname <bold>to<reset> title <bold>privileges<reset> privileges
Promotes or demotes a player to a specific title within a corp, and modifies their privileges.
<bold>username<reset> - the player you want to promote.
<bold>corpname<reset> - the name of the corporation you want to promote the player within.
<bold>title<reset> - the title you want to assign to the player.
<bold>privileges<reset> - the privileges you want to grant or remove from the player, prefixed with + or - respectively, separated by spaces.
The following privileges exist:
<bold>holder<reset> - The owner of the corp (implies all other privileges).
<bold>hire<reset> - Can hire new people into the corp.
<bold>fire<reset> - Can fire people from the corp.
<bold>promote<reset> - Can promote/demote people.
<bold>war<reset> - Can declare war against other corps on behalf of the corp.
<bold>configure<reset> - Can change the settings of the corp.
<bold>finance<reset> - Can access (or embezzle!) the corps funds.
"corp info": "<bold>corp info<reset> corpname Get information about a corp."
"corp allow combat": "<bold>corp allow combat<reset> Allow the corp to consent to combat on your behalf."
"corp disallow combat": "<bold>corp disallow combat<reset> Don't allow the corp to consent to combat on your behalf."
"corp config": |-
<bold>corp config<reset> corpname setting
Configure various settings for your corp, such as member privileges and combat consent requirements.
<bold>corpname<reset> - the name of your corp.
<bold>setting<reset> - one of the following options:
<bold>allow combat required<reset> - Requires that all new members have the allow combat setting.
<bold>allow combat not required<reset> - Stops requiring that new members have allow combat on.
<bold>base privileges privilege_name privilege_name<reset> - Sets the privileges that new members receive when they join the corporation. See <bold>help corp promote<reset> for privileges.
"corp sub": |-
<bold>corp sub<reset> type <bold>from<reset> corpname
Subscribes to corp broadcasts. Type is one of:
chat - Messages from users.
notice - Important corp notices.
connect - Connecting players.
reward - Rewards paid to the corp.
death - Deaths of players in the corp.
consent - Changes to corp consents (war declarations).
"corp unsub": |-
<bold>corp unsub<reset> type <bold>from<reset> corpname
Unsubscribe from corp broadcasts. Type is one of:
chat - Messages from users.
notice - Important corp notices.
connect - Connecting players.
reward - Rewards paid to the corp.
death - Deaths of players in the corp.
consent - Changes to corp consents (war declarations).
"corp order": "<bold>corp order<reset> corpname <bold>as<reset> number Set the position of the corp in your list."
c: >-
<bold>c<reset> sends a message to one of your corporations. Use it as <bold>c<reset> message to send message to your
first corp (use <bold>corp order<reset> to choose which corp is first). Or explicitly name a corp like this:
<bold>corp @<reset>corpname message
"install": "<bold>install<reset> item <bold>on door to<reset> direction Installs hardware such as a lock on a door."
"uninstall": "<bold>uninstall<reset> item <bold>from door to<reset> direction Removes installed hardware such as a lock on a door."
"gear": "<bold>gear<reset> See equipment you have on, and what protection it offers you."
"delete": |-
Delete is the most destructive command in the game - used to reset your character, or even give it up forever. All commands below echo out a command including a single-use command you can use to confirm the command - they don't take effect without the code. This prevents accidentally pasting in dangerous commands.
<bold>delete character forever<reset> Destroy your character permanently. The command starts a one week countdown at the end of which your account and character (and anything in their possession) are permanently destroyed (as in we irreversibly wipe your data from the server), and any places you are renting get evicted. The command disconnects you immediately. If you reconnect and log in again within the week, the countdown is stopped and the deletion aborted. After the one week, anyone can register again with the same username. At the end of the week, when your character is destroyed, it no longer counts towards the limit of 5 usernames per person.
<bold>delete stats<reset> Kills your character instantly (leaving anything you are carrying on your corpse) and sends you back to the choice room to pick new stats. Any XP gained, apart from XP from journals, is reset back to 0.
eat: |-
If you don't eat, you will get hungry, and eventually be so starving you can barely move. Try <bold>eat<reset> something, in relation to some food in your inventory. If you don't have food, try to find a restaurant, or cut a steak with a butcher's knife from a dead body.
drink: |-
If you don't drink, you will get so thirsty you can barely move. Try <bold>drink from<reset> something, using either a bottle in your inventory, or a fountain or similar outside. See also <bold>help fill<reset> to learn how to fill containers.
fill: |-
You can fill a container (like a bottle) using a command like <bold>fill<reset> container <bold>from<reset> source, where source is where you want to get the liquid from (e.g. a fountain), and container is what you want to put the liquid in. If you don't have a container, you might be able to buy one at a shop, or craft one.
put: |-
You can put something in a container using a command like <bold>put<reset> thing <bold>in<reset> container. See also <bold>help get<reset> to learn how to get them back out.
get: |-
You can get something out of a container using a command like <bold>get<reset> thing <bold>from<reset> container.
sharing: &sharing |-
Sharing lets you share knowledge with another player. Because knowledge is power, sharing will increase the stats of both players for a little while (10 minutes); the better a job you do at sharing, the greater the impact on your stats (up to a maximum of two points of increase to every stat). If you share twice, you will forget what you from the first sharing, and the more recent one will affect your stats. Sharing is therefore a great way to collaborate with another player to temporarily become more capable. However, sharing effectively takes great skill [both player skill, and share skill in the game].
Type <bold>share knowledge with<reset> player to start a sharing session. You will immediately begin sharing, and if you type <bold>share status<reset>, you'll start to see growth in certain interest types. Sharing stops when the pair sharing get to 100% on any interest level and get bored - so you'll want to change topics to avoid that. To get the best score, try to get all interest levels balanced to just below the maximum before you go over on any. However, there are a couple of catches: firstly, changing anything about the conversation is going to be awkward (might fail) if you try too soon after the last change - the higher your Share skill, the faster you can change with a high chance of success. Secondly, to discuss some topics, you need to switch to an appropriate conversation style (amicable, playful, or serious) for the topic first - which could take even longer if your skill level is low. It is a lot easier if yyou work with your conversational partner - they might be able to change earlier.
Throughout the conversation, the game will tell you what styles you can change to, and what topics you can change to under the current style. Just type the name of the topic or style to switch. Each topic has a different rate at which it increases or decreases different interest types.
If you find the pace of the conversation too fast or slow, you can also change the pace with <bold>share slowly<reset>, <bold>share normally<reset> or <bold>share quickly<reset>. Keep in mind changing the pace counts as a change, like changing topics - but once you have set the pace, it scales how fast conversational interest levels grow.
amicable: *sharing
exploring: *sharing
fishing: *sharing
good: *sharing
joking: *sharing
parody: *sharing
play: *sharing
serious: *sharing
share: *sharing
slow: *sharing
normal: *sharing
intense: *sharing
surviving: *sharing
thoughts: *sharing
roaming: *sharing
butcher: &butcher >-
Use the <bold>butcher<reset> command to cut everything possible out of a corpse.
Alternatively, you can cut one part from a corpse by doing something like:
<bold>cut<reset> steak <bold>from<reset> dead dog
This typically requires craft skill.
cut: *butcher
hire: &hiring >-
Use <bold>hire<reset> npc to hire an NPC (e.g. a Roboporter) into your personal employment.
Use <bold>fire<reset> npc to fire them and terminate their employment.
Please note that these commands hire them personally. See also <bold>corp hire<reset>
and <bold>corp fire<reset> to learn about hiring into corps.
fire: *hiring
follow: &follow >-
Use <bold>follow<reset> player to start following a player around.
Use <bold>unfollow<reset> to stop following.
unfollow: *follow
open: &doors >-
Use <bold>open<reset> direction to open a door (and attempting to unlock).
Doors will auto-open just by moving through them, but opening lets you look first,
or let someone else in.
Use <bold>close<reset> direction to close the door in that direction (and lock if applicable).
doors: *doors
close: *doors
hack: >-
If you've found a room where you can install a wristpad hack, use
<bold>hack<reset> hackname to install that hack on your wristpad.
The room description will generally tell you what hackname to use.
improvise: &improvise >-
If you don't have a crafting bench or instructions, you might still be able to make something.
Try <bold>improv with<reset> item to explore what you can make with item.
Once you know what to make, try <bold>improv<reset> output <bold>from<reset> item1, item2, ...
This will let you use as many items as you need.
improv: *improvise
improvize: *improvise
make: &craft >-
To craft, you generally need some form of instructions, a craft bench of some form (which might be a stove, specialised workbench, etc...), and some ingredients.
To start crafting, firstly <bold>put<reset> the instructions (or the entire book) into the craft bench. See <bold>help put<reset> for help on putting. Likewise put the ingredients in the bench
Then use <bold>make<reset> thing <bold>on<reset> bench, where thing is the thing the instructions describe how to
make. The thing will end up in the craft bench. Pay attention to make sure you get it out, and not the recipe.
craft: *craft
crafting: *craft
load: &loading >-
To load an item onto an NPC such as a roboporter (so it will carry your stuff around for you), try:
<bold>load<reset> item <bold>onto<reset> npc
You can unload with <bold>unload<reset> item <bold>from<reset> npc
unload: *loading
pay: >-
Use <bold>pay<reset> $amount <bold> [<bold>from<reset> corp] to recipient
Leave off the <bold>from<reset> corp part if it is from you.
Recipient can be a corp or a user.
You must have finance privileges in the corp you are paying from.
plug: >-
Use <bold>plug in<reset> equipment to plug something electrical in to charge.
It needs to be on the ground (not in inventory), and you need to do it where there is power.
recline: &posture >-
Use <bold>sit<reset> to sit down.
Use <bold>recline<reset> to recline (lie down).
Use <bold>stand<reset> to stand up.
sit: *posture
stand: *posture
stand up: *posture
rent: &rent >-
Renting property gives you somewhere to stay, or gives your corp an HQ.
To rent, go to the place where property is on offer (usually the lobby), and use
<bold>rent<reset> roomtype
To rent for a corp, use:
<bold>rent<reset> roomtype <bold>for<reset> corpname
To stop renting, use:
<bold>vacate<reset> roomtype [<bold>for<reset> corpname]
vacate: *rent
renting: *rent
property: *rent
report abuse: &report
If someone breaks the rules (for example, by posting content that isn't allowed, or by harassing another user),
immediately type <bold>report abuse<reset> to record the last 80 things the MUD sent to you, and give you a report
ID. Once you have a report ID, send it to staff@blastmud.org with a description of the problem. We'll be able to
look up the report ID to see what you are referring to.
report: *report
abuse: *report
harassment: *report
scan: >-
Use the <bold>scan<reset> command to scan a QR code found in the game with your wristpad.
score: &score >-
Use the <bold>score<reset> command to see your stats, skills, experience, and hacks.
sc: *score
scavenge: &scavenge >-
Use the <bold>scavenge<reset> or <bold>search<reset> command to look for hidden treasure (or junk) at the current location.
search: *scavenge
sign: Use <bold>sign<reset> thing to sign a contract.
status: &stats >-
Use <bold>stats<reset> to see your character's basic stats and credits.
st: *stats
stat: *stats
stats: *stats
stop: Use <bold>stop<reset> to stop any queued actions, and reverse the current one if possible.
turn: &turn >-
Use <bold>turn on<reset> thing to turn something on.
Use <bold>turn off<reset> thing to turn something off.
use: Use <bold>use<reset> item [<bold>on<reset> character] to use something on someone (optional).
wear: &clothes >-
Use <bold>wear<reset> clothing to wear something.
Use <bold>remove<reset> clothing to remove some clothing.
Clothing must be removed in the opposite order it is layered on.
remove: *clothes
write: Use <bold>write<reset> message <bold>on<reset> item to write on something.

View File

@ -0,0 +1,12 @@
"": |-
Type <bold>help <lt>topicname><reset> to learn about a topic. Most commands can be used as a topicname.
Topics of interest to unregistered users:
<bold>register<reset>\tLearn about the <bold>register<reset> command.
<bold>login<reset>\tLearn how to log in as an existing user.
register: |-
Registers a new user. You are allowed at most 5 at once.
<bold>register <lt>username> <lt>password> <lt>email><reset>
Email will be used to check you don't have too many accounts and in case you need to reset your password.
login: |-
Logs in as an existing user.
<bold>login <lt>username> <lt>password<reset>

View File

@ -0,0 +1,246 @@
use super::{
get_player_item_or_fail, get_user_or_fail, get_user_or_fail_mut, search_items_for_user,
user_error, UResult, UserError, UserVerb, UserVerbRef, VerbContext,
};
use crate::{
db::ItemSearchParams,
models::{
item::{Item, ItemFlag, ItemSpecialData},
task::{Task, TaskDetails, TaskMeta},
},
regular_tasks::{TaskHandler, TaskRunContext},
static_content::npc::npc_by_code,
DResult,
};
use ansi::ansi;
use async_trait::async_trait;
use chrono::{Duration, Utc};
use std::sync::Arc;
use std::time;
static RESIGNATION_NOTICE: &'static str = ansi!("You're a <red>terrible employer<reset>, you <bold>HAVEN'T PAID MY WAGES!<reset> Do you even have the credits? I don't work for free, so you can forget it! I quit, effective immediately!");
pub struct ChargeWagesTaskHandler;
#[async_trait]
impl TaskHandler for ChargeWagesTaskHandler {
async fn do_task(&self, ctx: &mut TaskRunContext) -> DResult<Option<time::Duration>> {
let npc_code = match &mut ctx.task.details {
TaskDetails::ChargeWages { npc } => npc,
_ => Err("Expected ChargeWages type")?,
};
let hire_dat = match npc_by_code()
.get(npc_code.as_str())
.and_then(|npci| npci.hire_data.as_ref())
{
None => return Ok(None), // I guess it is free until fixed in the db?
Some(d) => d,
};
let npc: Arc<Item> = match ctx.trans.find_item_by_type_code("npc", &npc_code).await? {
None => return Ok(None),
Some(d) => d,
};
let hired_by = match npc.special_data.as_ref() {
Some(ItemSpecialData::HireData {
hired_by: Some(hired_by),
}) => hired_by,
_ => return Ok(None),
};
let mut bill_user = match ctx.trans.find_by_username(&hired_by).await? {
None => {
// Happens if the user is deleted while hiring an NPC.
let mut npc_mut: Item = (*npc).clone();
npc_mut.special_data = Some(ItemSpecialData::HireData { hired_by: None });
// No player, so the NPC fires itself.
hire_dat
.handler
.fire_handler(&ctx.trans, &npc, &mut npc_mut)
.await?;
ctx.trans.save_item_model(&npc_mut).await?;
return Ok(None);
}
Some(user) => user,
};
let bill_player = ctx
.trans
.find_item_by_type_code("player", &hired_by)
.await?
.ok_or_else(|| "Player hiring NPC missing but user still there.")?;
let sess_and_dat = ctx.trans.find_session_for_player(&hired_by).await?;
if hire_dat.price > bill_user.credits {
if let Some((sess, _sess_dat)) = sess_and_dat {
ctx.trans
.queue_for_session(
&sess,
Some(&format!(
"<yellow>{} says: <reset><bold>\"{}\"<reset>\n",
&npc.display_for_sentence(1, false),
RESIGNATION_NOTICE
)),
)
.await?;
}
let mut npc_mut = (*npc).clone();
hire_dat
.handler
.fire_handler(&ctx.trans, &bill_player, &mut npc_mut)
.await?;
npc_mut.special_data = Some(ItemSpecialData::HireData { hired_by: None });
ctx.trans.save_item_model(&npc_mut).await?;
return Ok(None);
}
bill_user.credits -= hire_dat.price;
ctx.trans.save_user_model(&bill_user).await?;
match sess_and_dat.as_ref() {
None => {},
Some((sess, _sess_dat)) => ctx.trans.queue_for_session(
sess, Some(&format!(
ansi!("<yellow>Your wristpad beeps for a deduction of ${} for wages for {}.<reset>\n"),
&hire_dat.price, &npc.display_for_sentence(1, false)
))
).await?
}
Ok(Some(time::Duration::from_secs(hire_dat.frequency_secs)))
}
}
pub static CHARGE_WAGES_HANDLER: &'static (dyn TaskHandler + Sync + Send) = &ChargeWagesTaskHandler;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let npc_name = remaining.trim();
let player_item = get_player_item_or_fail(ctx).await?;
if npc_name == "" {
let staff = ctx
.trans
.find_staff_by_hirer(&player_item.item_code)
.await?;
let mut msg: String = String::new();
if staff.is_empty() {
msg.push_str("You aren't currently employing anyone.\n");
} else {
msg.push_str("You are currently employing:\n");
for emp in staff {
if let Some(hire_dat) = npc_by_code()
.get(emp.item_code.as_str())
.as_ref()
.and_then(|npci| npci.hire_data.as_ref())
{
msg.push_str(&format!(
"* {} @ ${} / {}\n",
emp.display_for_sentence(1, false),
hire_dat.price,
humantime::format_duration(std::time::Duration::from_secs(
hire_dat.frequency_secs,
)),
));
}
}
}
ctx.trans.queue_for_session(ctx.session, Some(&msg)).await?;
return Ok(());
}
let to_hire_if_free = search_items_for_user(
ctx,
&ItemSearchParams {
include_loc_contents: true,
item_type_only: Some("npc"),
..ItemSearchParams::base(&player_item, npc_name)
},
)
.await?;
let npc = to_hire_if_free
.into_iter()
.find(|it| {
it.flags.contains(&ItemFlag::Hireable)
&& match it.special_data {
Some(ItemSpecialData::HireData { hired_by: Some(_) }) => false,
_ => true,
}
})
.ok_or_else(|| UserError("Nothing here is available for hire right now.".to_owned()))?;
let hire_dat = npc_by_code()
.get(npc.item_code.as_str())
.as_ref()
.and_then(|npci| npci.hire_data.as_ref())
.ok_or_else(|| UserError("Sorry, I've forgotten how to hire that out!".to_owned()))?;
let user = get_user_or_fail(ctx)?;
if user.credits < hire_dat.price {
user_error(format!(
ansi!(
"<yellow>{} says: <reset><bold>\"You wouldn't be able to afford me.\"<reset>"
),
npc.display_for_sentence(1, false)
))?;
}
if ctx
.trans
.count_staff_by_hirer(&player_item.item_code)
.await?
>= 5
{
user_error(
"You don't think you could supervise that many employees at once!".to_owned(),
)?;
}
let user_mut = get_user_or_fail_mut(ctx)?;
user_mut.credits -= hire_dat.price;
ctx.trans
.queue_for_session(
&ctx.session,
Some(&format!(
"You hire {}, and your wristpad beeps for a deduction of ${}.\n",
npc.display_for_sentence(1, false),
hire_dat.price
)),
)
.await?;
let mut npc_mut = (*npc).clone();
hire_dat
.handler
.hire_handler(&ctx.trans, &ctx.session, &player_item, &mut npc_mut)
.await?;
npc_mut.special_data = Some(ItemSpecialData::HireData {
hired_by: Some(player_item.item_code.clone()),
});
ctx.trans.save_item_model(&npc_mut).await?;
ctx.trans
.upsert_task(&Task {
meta: TaskMeta {
task_code: npc.item_code.clone(),
is_static: false,
recurrence: None, // Managed by the handler.
next_scheduled: Utc::now()
+ Duration::try_seconds(hire_dat.frequency_secs as i64).unwrap(),
..Default::default()
},
details: TaskDetails::ChargeWages {
npc: npc.item_code.clone(),
},
})
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -1,10 +1,15 @@
use super::{VerbContext, UserVerb, UserVerbRef, UResult}; use super::{UResult, UserVerb, UserVerbRef, VerbContext};
use async_trait::async_trait; use async_trait::async_trait;
pub struct Verb; pub struct Verb;
#[async_trait] #[async_trait]
impl UserVerb for Verb { impl UserVerb for Verb {
async fn handle(self: &Self, _ctx: &mut VerbContext, _verb: &str, _remaining: &str) -> UResult<()> { async fn handle(
self: &Self,
_ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
Ok(()) Ok(())
} }
} }

View File

@ -0,0 +1,458 @@
use super::{
get_player_item_or_fail, parsing::parse_count, search_item_for_user, search_items_for_user,
user_error, ItemSearchParams, UResult, UserError, UserVerb, UserVerbRef, VerbContext,
};
use crate::{
language::{self, indefinite_article},
models::item::Item,
regular_tasks::queued_command::{
queue_command_and_save, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::{
comms::broadcast_to_room,
destroy_container,
skills::{crit_fail_penalty_for_skill, skill_check_and_grind},
urges::change_stress_considering_cool,
},
static_content::possession_type::{
improv_by_ingredient, improv_by_output, possession_data, possession_type_names,
PossessionData, PossessionType,
},
};
use ansi::ansi;
use async_trait::async_trait;
use rand::seq::IteratorRandom;
use rand::seq::SliceRandom;
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time;
pub struct WithQueueHandler;
#[async_trait]
impl QueueCommandHandler for WithQueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error("The dead aren't very good at improvisation.".to_owned())?;
}
let item_id = match ctx.command {
QueueCommand::ImprovWith { possession_id } => possession_id,
_ => user_error("Unexpected command".to_owned())?,
};
if ctx.item.urges.as_ref().map(|u| u.stress.value).unwrap_or(0) > 7000 {
user_error(
ansi!(
"You are too tired and stressed to consider crafts. Maybe try to \
<bold>sit<reset> or <bold>recline<reset> for a bit!"
)
.to_owned(),
)?;
}
let item = match ctx
.trans
.find_item_by_type_code("possession", &item_id)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != ctx.item.refstr() {
user_error("You try improvising but realise you no longer have it.".to_owned())?;
}
broadcast_to_room(
&ctx.trans,
&ctx.item.location,
None,
&format!(
"{} tries to work out what {} can make from {}.\n",
&ctx.item.display_for_sentence(1, true),
&ctx.item.pronouns.subject,
&item.display_for_sentence(1, false),
),
)
.await?;
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error("The dead aren't very good at improvisation.".to_owned())?;
}
let item_id = match ctx.command {
QueueCommand::ImprovWith { possession_id } => possession_id,
_ => user_error("Unexpected command".to_owned())?,
};
let item = match ctx
.trans
.find_item_by_type_code("possession", &item_id)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != ctx.item.refstr() {
user_error("You try improvising but realise you no longer have it.".to_owned())?;
}
let session = if ctx.item.item_type == "player" {
ctx.trans
.find_session_for_player(&ctx.item.item_code)
.await?
} else {
None
};
let opts: Vec<&'static PossessionData> = improv_by_ingredient()
.get(
item.possession_type
.as_ref()
.ok_or_else(|| UserError("You can't improvise with that!".to_owned()))?,
)
.ok_or_else(|| {
UserError(format!(
"You can't think of anything you could make with {}",
item.display_for_sentence(1, false)
))
})?
.iter()
.filter_map(|it| possession_data().get(&it.output).map(|v| *v))
.collect();
let result_data = opts
.as_slice()
.choose(&mut rand::thread_rng())
.ok_or_else(|| {
UserError(format!(
"You can't think of anything you could make with {}",
item.display_for_sentence(1, false)
))
})?;
if let Some((sess, _)) = session {
ctx.trans
.queue_for_session(
&sess,
Some(&format!(
"You think you could make {} {} from {}\n",
indefinite_article(result_data.display),
result_data.display,
item.display_for_sentence(1, false)
)),
)
.await?;
}
Ok(())
}
}
pub struct FromQueueHandler;
#[async_trait]
impl QueueCommandHandler for FromQueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error("The dead aren't very good at improvisation.".to_owned())?;
}
let (already_used, item_ids) = match ctx.command {
QueueCommand::ImprovFrom {
possession_ids,
already_used,
..
} => (already_used, possession_ids),
_ => user_error("Unexpected command".to_owned())?,
};
if !already_used.is_empty() {
return Ok(time::Duration::from_secs(1));
}
for item_id in item_ids {
let item = match ctx
.trans
.find_item_by_type_code("possession", &item_id)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != ctx.item.refstr() {
user_error("You try improvising but realise you no longer have the things you'd planned to use."
.to_owned())?;
}
}
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error("The dead aren't very good at improvisation.".to_owned())?;
}
let (output, possession_ids, already_used) = match ctx.command {
QueueCommand::ImprovFrom {
output,
possession_ids,
already_used,
} => (output, possession_ids, already_used),
_ => user_error("Unexpected command".to_owned())?,
};
let craft_data = improv_by_output().get(&output).ok_or_else(|| {
UserError("You don't think it is possible to improvise that.".to_owned())
})?;
let mut ingredients_left: Vec<PossessionType> = craft_data.inputs.clone();
let mut to_destroy_if_success: Vec<Arc<Item>> = Vec::new();
for item_id in already_used {
let item = ctx
.trans
.find_item_by_type_code("possession", &item_id)
.await?
.ok_or_else(|| UserError("Item used in crafting not found.".to_owned()))?;
to_destroy_if_success.push(item.clone());
let possession_type = item
.possession_type
.as_ref()
.ok_or_else(|| UserError("Item used in crafting not a possession.".to_owned()))?;
if let Some(match_pos) = ingredients_left.iter().position(|pt| pt == possession_type) {
ingredients_left.remove(match_pos);
}
}
let mut possession_id_iter = possession_ids.iter();
let session = if ctx.item.item_type == "player" {
ctx.trans
.find_session_for_player(&ctx.item.item_code)
.await?
} else {
None
};
match possession_id_iter.next() {
None => {
let choice = ingredients_left
.iter()
.choose(&mut rand::thread_rng())
.clone();
match choice {
// Nothing left to add, and nothing needed - success!
None => {
for item in to_destroy_if_success {
destroy_container(&ctx.trans, &item).await?;
}
let mut new_item: Item = craft_data.output.clone().into();
new_item.item_code = ctx.trans.alloc_item_code().await?.to_string();
new_item.location = ctx.item.refstr();
ctx.trans.create_item(&new_item).await?;
broadcast_to_room(
&ctx.trans,
&ctx.item.location,
None,
&format!(
"{} proudly holds up the {} {} just made.\n",
&ctx.item.display_for_sentence(1, true),
&new_item.display_for_sentence(1, false),
&ctx.item.pronouns.subject
),
)
.await?;
}
// Nothing left to add, but recipe incomplete.
Some(missing_type) => {
let possession_data =
possession_data().get(missing_type).ok_or_else(|| {
UserError(
"It looks like it's no longer possible to improvise that."
.to_owned(),
)
})?;
user_error(format!(
"You realise you'll also need {} {} to craft that.",
language::indefinite_article(possession_data.display),
possession_data.display
))?;
}
}
}
Some(possession_id) => {
let item = ctx
.trans
.find_item_by_type_code("possession", &possession_id)
.await?
.ok_or_else(|| {
UserError(
"An item you planned to use for crafting seems to be gone.".to_owned(),
)
})?;
if !ingredients_left.contains(
item.possession_type
.as_ref()
.ok_or_else(|| UserError("Uncraftable item used.".to_owned()))?,
) {
user_error(format!(
"You try adding {}, but it doesn't really seem to fit right.",
&item.display_for_sentence(1, false)
))?;
}
let skill_result = skill_check_and_grind(
&ctx.trans,
ctx.item,
&craft_data.skill,
craft_data.difficulty,
)
.await?;
if skill_result <= -0.5 {
change_stress_considering_cool(&ctx.trans, &mut ctx.item, 1000).await?;
crit_fail_penalty_for_skill(&ctx.trans, ctx.item, &craft_data.skill).await?;
ctx.trans
.delete_item(&item.item_type, &item.item_code)
.await?;
if let Some((sess, _)) = session {
ctx.trans
.queue_for_session(
&sess,
Some(&format!(
"You try adding {}, but it goes badly and you waste it.\n",
&item.display_for_sentence(1, false)
)),
)
.await?;
}
} else if skill_result <= 0.0 {
change_stress_considering_cool(&ctx.trans, &mut ctx.item, 500).await?;
if let Some((sess, _)) = session {
ctx.trans
.queue_for_session(
&sess,
Some(&format!(
"You try and fail at adding {}.\n",
&item.display_for_sentence(1, false)
)),
)
.await?;
}
} else {
if let Some((sess, _)) = session {
ctx.trans
.queue_for_session(
&sess,
Some(&format!(
"You try adding {}.\n",
&item.display_for_sentence(1, false),
)),
)
.await?;
}
let mut new_possession_ids = possession_ids.clone();
new_possession_ids.remove(possession_id);
let mut new_already_used = already_used.clone();
new_already_used.insert(possession_id.clone());
ctx.item.queue.push_front(QueueCommand::ImprovFrom {
output: output.clone(),
possession_ids: new_possession_ids,
already_used: new_already_used,
});
}
}
}
Ok(())
}
}
async fn improv_query(
ctx: &mut VerbContext<'_>,
player_item: &Item,
with_what: &str,
) -> UResult<()> {
let item = search_item_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
..ItemSearchParams::base(player_item, with_what)
},
)
.await?;
if item.item_type != "possession" {
user_error("You can't improvise with that!".to_owned())?
}
queue_command_and_save(
ctx,
player_item,
&QueueCommand::ImprovWith {
possession_id: item.item_code.clone(),
},
)
.await
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let rtrim = remaining.trim();
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error("The dead aren't very good at improvisation.".to_owned())?;
}
if rtrim.starts_with("with ") {
return improv_query(ctx, &player_item, rtrim["with ".len()..].trim_start()).await;
}
let (output, inputs_str) = rtrim.split_once(" from ").ok_or_else(
|| UserError(ansi!("Try <bold>improvise with <reset>item or <bold>improvise<reset> item <bold>from<reset> item, item, ...<reset>").to_owned()))?;
let output_type: PossessionType = match possession_type_names()
.get(&output.trim().to_lowercase())
.map(|x| x.as_slice())
.unwrap_or_else(|| &[])
{
[] => user_error("I don't recognise the thing you want to make.".to_owned())?,
[t] => t.clone(),
_ => user_error(
"You'll have to be more specific about what you want to make.".to_owned(),
)?,
};
let inputs = inputs_str.split(",").map(|v| v.trim());
let mut input_ids: BTreeSet<String> = BTreeSet::new();
for mut input in inputs {
let mut use_limit = Some(1);
if input == "all" || input.starts_with("all ") {
input = input[3..].trim();
use_limit = None;
} else if let (Some(n), remaining2) = parse_count(input) {
use_limit = Some(n);
input = remaining2;
}
let items = search_items_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
limit: use_limit.unwrap_or(100),
..ItemSearchParams::base(&player_item, input)
},
)
.await?;
for item in items {
if item.item_type != "possession" {
user_error(format!(
"You can't improvise with {}!",
&item.display_for_sentence(1, false)
))?
}
input_ids.insert(item.item_code.to_owned());
}
}
queue_command_and_save(
ctx,
&player_item,
&QueueCommand::ImprovFrom {
output: output_type,
possession_ids: input_ids,
already_used: BTreeSet::new(),
},
)
.await
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,97 @@
use super::{
get_player_item_or_fail, search_item_for_user, user_error, UResult, UserError, UserVerb,
UserVerbRef, VerbContext,
};
use crate::{
db::ItemSearchParams,
models::item::ItemFlag,
static_content::{possession_type::possession_data, room::Direction},
};
use ansi::ansi;
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let (install_what_raw, what_dir_raw) = match remaining.rsplit_once(" on door to ") {
None => user_error(ansi!("Install where? Try <bold>install<reset> <lt>lock> <bold>on door to<reset> <lt>direction>").to_owned())?,
Some(v) => v
};
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error(
"Apparently, you have to be alive to work as an installer.\
So discriminatory!"
.to_owned(),
)?;
}
let item = search_item_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
..ItemSearchParams::base(&player_item, install_what_raw.trim())
},
)
.await?;
if item.item_type != "possession" {
user_error("You can't install that!".to_owned())?;
}
let handler = match item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
.and_then(|pd| pd.install_handler)
{
None => user_error("You can't install that!".to_owned())?,
Some(h) => h,
};
let (loc_t, loc_c) = player_item
.location
.split_once("/")
.ok_or_else(|| UserError("Invalid current location".to_owned()))?;
let loc_item = ctx
.trans
.find_item_by_type_code(loc_t, loc_c)
.await?
.ok_or_else(|| UserError("Can't find your location".to_owned()))?;
if loc_item.owner.as_ref() != Some(&player_item.refstr())
|| !loc_item.flags.contains(&ItemFlag::PrivatePlace)
{
user_error(
"You can only install things while standing in a private room you own. \
If you are outside, try installing from the inside."
.to_owned(),
)?;
}
let dir = Direction::parse(what_dir_raw.trim())
.ok_or_else(|| UserError("Invalid direction.".to_owned()))?;
loc_item
.door_states
.as_ref()
.and_then(|ds| ds.get(&dir))
.ok_or_else(|| {
UserError(
"No door to that direction in this room - are you on the wrong side?"
.to_owned(),
)
})?;
handler
.install_cmd(ctx, &player_item, &item, &loc_item, &dir)
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,76 @@
use super::{get_player_item_or_fail, UResult, UserVerb, UserVerbRef, VerbContext};
use crate::{
language::weight,
models::item::{Item, LocationActionType},
};
use async_trait::async_trait;
use itertools::Itertools;
use std::sync::Arc;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
ctx.trans
.queue_for_session(
ctx.session,
Some("The dead don't really have an inventory.\n"),
)
.await?;
}
let inv = ctx
.trans
.find_items_by_location(&format!(
"{}/{}",
&player_item.item_type, &player_item.item_code
))
.await?;
let all_groups: Vec<Vec<&Arc<Item>>> = inv
.iter()
.group_by(|i| (i.display_for_sentence(1, false), &i.action_type))
.into_iter()
.map(|(_, g)| g.collect::<Vec<&Arc<Item>>>())
.collect::<Vec<Vec<&Arc<Item>>>>();
let mut response = String::new();
let mut total: u64 = 0;
for items in all_groups {
let item = items[0];
if item.item_type != "possession" {
continue;
}
let it_total = items.iter().map(|it| it.weight).sum();
total += it_total;
response.push_str(&format!(
"{} [{}]{}\n",
item.display_for_sentence(items.len(), true),
weight(it_total),
match item.action_type {
LocationActionType::Worn => " (worn)",
LocationActionType::Wielded => " (wielded)",
_ => "",
}
));
}
response.push_str(&format!(
"Total weight: {} ({} max)\n",
weight(total),
weight(player_item.max_carry())
));
if response == "" {
response.push_str("You aren't carrying anything.\n");
}
ctx.trans
.queue_for_session(ctx.session, Some(&response))
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,43 @@
use super::{get_player_item_or_fail, user_error, UResult, UserVerb, UserVerbRef, VerbContext};
use crate::models::item::ItemFlag;
use ansi::ansi;
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let requester = get_player_item_or_fail(ctx).await?;
let remaining = remaining.trim();
let state = if remaining == "on" {
true
} else if remaining == "off" {
false
} else {
return user_error(
ansi!("use <bold>staff_invincible on<reset> or <bold>staff_invincible off<reset>")
.to_owned(),
);
};
let mut requester = (*requester).clone();
requester.flags = requester
.flags
.into_iter()
.filter(|f| f != &ItemFlag::Invincible)
.collect();
if state {
requester.flags.push(ItemFlag::Invincible);
}
ctx.trans.save_item_model(&requester).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -1,16 +0,0 @@
use super::{
VerbContext, UserVerb, UserVerbRef, UResult
};
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, _remaining: &str) -> UResult<()> {
(*ctx.session_dat).less_explicit_mode = true;
ctx.trans.save_session_model(ctx.session, ctx.session_dat).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,77 @@
use super::{
get_player_item_or_fail, get_user_or_fail, user_error, UResult, UserVerb, UserVerbRef,
VerbContext,
};
use crate::{language, static_content::possession_type::possession_data, static_content::room};
use ansi::ansi;
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
let user = get_user_or_fail(ctx)?;
if player_item.death_data.is_some() {
user_error(
"Nobody seems to offer you any prices... possibly because you're dead.".to_owned(),
)?
}
let (heretype, herecode) = player_item
.location
.split_once("/")
.unwrap_or(("room", "repro_xv_chargen"));
if heretype != "room" {
user_error("Can't list stock because you're not in a shop.".to_owned())?;
}
let room = match room::room_map_by_code().get(herecode) {
None => user_error("Can't find that shop.".to_owned())?,
Some(r) => r,
};
if room.stock_list.is_empty() {
user_error("Can't list stock because you're not in a shop.".to_owned())?
}
let mut msg = String::new();
msg.push_str(&format!(
ansi!("<bold><bgblue><white>| {:40} | {:15} |<reset>\n"),
ansi!("Item"),
ansi!("Price")
));
for stock in &room.stock_list {
if !stock.can_buy {
continue;
}
if let Some(possession_type) = possession_data().get(&stock.possession_type) {
let display = &possession_type.display;
let mut price = stock.list_price;
if stock.poverty_discount && price > user.credits {
price = user.credits;
}
msg.push_str(&format!(
"| {:40} | {:15.2} |\n",
&language::caps_first(&display),
&price
))
}
}
msg.push_str(ansi!(
"\nUse <bold>buy<reset> item to purchase something.\n"
));
ctx.trans
.queue_for_session(&ctx.session, Some(&msg))
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,124 @@
use super::{
get_player_item_or_fail, parsing::parse_count, search_item_for_user, search_items_for_user,
user_error, UResult, UserVerb, UserVerbRef, VerbContext,
};
use crate::{
db::ItemSearchParams,
models::item::{ItemFlag, ItemSpecialData},
services::{
capacity::{check_item_capacity, check_item_ref_capacity, CapacityLevel},
comms::broadcast_to_room,
},
};
use ansi::ansi;
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
verb: &str,
remaining: &str,
) -> UResult<()> {
let reverse: bool = verb == "unload";
let sep = if reverse { "from" } else { "onto" };
let (mut item_name, npc_name) = match remaining.split_once(sep) {
None => user_error(format!(
ansi!("I couldn't understand that. Try <bold>{}<reset> item {} npc"),
verb, sep
))?,
Some(v) => v,
};
let player_item = get_player_item_or_fail(ctx).await?;
let npc = search_item_for_user(
ctx,
&ItemSearchParams {
include_loc_contents: true,
..ItemSearchParams::base(&player_item, npc_name.trim())
},
)
.await?;
if !npc.flags.contains(&ItemFlag::CanLoad) {
user_error(format!("You can't {} things {} that!", verb, sep))?
}
match npc.special_data.as_ref() {
Some(ItemSpecialData::HireData {
hired_by: Some(hired_by),
}) if hired_by == &player_item.item_code => {}
_ => user_error(format!(
"{} doesn't seem to be letting you do that. Try hiring {} first!",
npc.display_for_sentence(1, false),
npc.pronouns.object
))?,
}
let mut item_limit = Some(1);
if item_name == "all" || item_name.starts_with("all ") {
item_name = item_name[3..].trim();
item_limit = None;
} else if let (Some(n), remaining2) = parse_count(item_name) {
item_limit = Some(n);
item_name = remaining2;
}
let items = search_items_for_user(
ctx,
&ItemSearchParams {
include_contents: reverse,
include_loc_contents: !reverse,
item_type_only: Some("possession"),
limit: item_limit.unwrap_or(100),
..ItemSearchParams::base(&npc, item_name.trim())
},
)
.await?;
for item in items {
if reverse {
match check_item_ref_capacity(&ctx.trans, &player_item.location, item.weight)
.await?
{
CapacityLevel::AboveItemLimit => user_error(
"There is not enough space here to unload another item.".to_owned(),
)?,
_ => {}
}
} else {
match check_item_capacity(&ctx.trans, &npc, item.weight).await? {
CapacityLevel::AboveItemLimit | CapacityLevel::OverBurdened => {
user_error("There is not enough capacity to load that item.".to_owned())?
}
_ => {}
}
}
let mut item_mut = (*item).clone();
item_mut.location = if reverse {
npc.location.clone()
} else {
npc.refstr()
};
ctx.trans.save_item_model(&item_mut).await?;
broadcast_to_room(
&ctx.trans,
&npc.location,
None,
&format!(
"{} {}s {} {} {}\n",
&npc.display_for_sentence(1, true),
verb,
&item.display_for_sentence(1, false),
sep,
&npc.pronouns.intensive
),
)
.await?;
}
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -1,35 +1,68 @@
use super::{VerbContext, UserVerb, UserVerbRef, UResult, user_error};
use super::look; use super::look;
use super::{get_player_item_or_fail, user_error, UResult, UserVerb, UserVerbRef, VerbContext};
use crate::services::urges::set_has_urges_if_needed;
use async_trait::async_trait; use async_trait::async_trait;
use tokio::time; use tokio::time;
pub struct Verb; pub struct Verb;
#[async_trait] #[async_trait]
impl UserVerb for Verb { impl UserVerb for Verb {
async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let (username, password) = match remaining.split_whitespace().collect::<Vec<&str>>()[..] { let (username, password) = match remaining.split_whitespace().collect::<Vec<&str>>()[..] {
[] | [_] => user_error("Too few options to login".to_owned())?, [] | [_] => user_error("Too few options to login".to_owned())?,
[username, password] => (username, password), [username, password] => (username, password),
_ => user_error("Too many options to login".to_owned())?, _ => user_error("Too many options to login".to_owned())?,
}; };
match ctx.trans.find_by_username(username).await? { let username_exact = match ctx.trans.find_by_username(username).await? {
None => user_error("No such user.".to_owned())?, None => user_error("No such user.".to_owned())?,
Some(user) => { Some(user) => {
time::sleep(time::Duration::from_secs(5)).await; time::sleep(time::Duration::from_secs(5)).await;
if !bcrypt::verify(password, &user.password_hash)? { if !bcrypt::verify(password, &user.password_hash)? {
user_error("Invalid password.".to_owned())? user_error("Invalid password.".to_owned())?
} }
let username_exact = user.username.clone();
*ctx.user_dat = Some(user); *ctx.user_dat = Some(user);
username_exact
} }
};
ctx.trans
.attach_user_to_session(username, ctx.session)
.await?;
if ctx
.trans
.check_task_by_type_code("DestroyUser", &username_exact)
.await?
{
ctx.trans
.queue_for_session(
ctx.session,
Some(
"Your username was scheduled to self-destruct - that has now been \
cancelled because you logged in.\n",
),
)
.await?;
ctx.trans
.delete_task("DestroyUser", &username_exact)
.await?;
} }
ctx.trans.attach_user_to_session(username, ctx.session).await?;
super::agree::check_and_notify_accepts(ctx).await?; super::agree::check_and_notify_accepts(ctx).await?;
if let Some(user) = ctx.user_dat { if let Some(user) = ctx.user_dat {
ctx.trans.save_user_model(user).await?; ctx.trans.save_user_model(user).await?;
look::VERB.handle(ctx, "look", "").await?; look::VERB.handle(ctx, "look", "").await?;
} }
let mut player_item = (*get_player_item_or_fail(ctx).await?).clone();
set_has_urges_if_needed(&ctx.trans, &mut player_item).await?;
ctx.trans.save_item_model(&player_item).await?;
Ok(()) Ok(())
} }

View File

@ -1,106 +1,576 @@
use super::{VerbContext, UserVerb, UserVerbRef, UResult, UserError, user_error, use super::{
get_player_item_or_fail, search_item_for_user}; get_player_item_or_fail,
use async_trait::async_trait; map::{render_map, render_map_dyn},
open::{is_door_in_direction, DoorSituation},
search_item_for_user, user_error, UResult, UserError, UserVerb, UserVerbRef, VerbContext,
};
#[double]
use crate::db::DBTrans;
use crate::{
db::{ItemSearchParams, ObjectRef},
language,
models::{
effect::EffectType,
item::{
DoorState, Item, ItemFlag, ItemSpecialData, LiquidDetails, LiquidType,
LocationActionType, Subattack,
},
},
services::{combat::max_health, skills::calc_level_gap},
static_content::{
dynzone,
possession_type::{possession_data, recipe_craft_by_recipe},
room::{self, room_map_by_code, Direction},
species::species_info_map,
},
};
use ansi::{ansi, flow_around, word_wrap}; use ansi::{ansi, flow_around, word_wrap};
use crate::db::ItemSearchParams; use async_trait::async_trait;
use crate::models::{item::{Item, LocationActionType, Subattack, ItemFlag}};
use crate::static_content::room::{self, Direction};
use itertools::Itertools; use itertools::Itertools;
use mockall_double::double;
use std::collections::BTreeSet;
use std::sync::Arc; use std::sync::Arc;
pub fn render_map(room: &room::Room, width: usize, height: usize) -> String { pub async fn describe_normal_item(
let mut buf = String::new(); player_item: &Item,
let my_loc = &room.grid_coords; ctx: &VerbContext<'_>,
let min_x = my_loc.x - (width as i64) / 2; item: &Item,
let max_x = min_x + (width as i64); ) -> UResult<()> {
let min_y = my_loc.y - (height as i64) / 2; let mut contents_desc = String::new();
let max_y = min_y + (height as i64);
for y in min_y..max_y { let mut items = ctx
for x in min_x..max_x { .trans
if my_loc.x == x && my_loc.y == y { .find_items_by_location(&format!("{}/{}", item.item_type, item.item_code))
buf.push_str(ansi!("<bgblue><red>()<reset>")) .await?;
items.sort_unstable_by(|it1, it2| {
(&it1.action_type)
.cmp(&it2.action_type)
.then((&it1.display).cmp(&it2.display))
});
let all_groups: Vec<Vec<&Arc<Item>>> = items
.iter()
.filter(|it| it.action_type != LocationActionType::Worn)
.group_by(|i| (i.display_for_sentence(1, false), &i.action_type))
.into_iter()
.map(|(_, g)| g.collect::<Vec<&Arc<Item>>>())
.collect::<Vec<Vec<&Arc<Item>>>>();
if all_groups.len() > 0 {
contents_desc.push_str(&(language::caps_first(&item.pronouns.subject)));
if item.item_type == "player" || item.item_type == "npc" {
contents_desc.push_str("'s carrying ");
} else {
contents_desc.push_str(" contains ");
}
let mut phrases = Vec::<String>::new();
for group_items in all_groups {
let head = &group_items[0];
let mut details = head.display_for_sentence(group_items.len(), false);
match head.action_type {
LocationActionType::Wielded => details.push_str(" (wielded)"),
LocationActionType::Worn => continue,
_ => {}
}
phrases.push(details);
}
let phrases_str: Vec<&str> = phrases.iter().map(|p| p.as_str()).collect();
contents_desc.push_str(&(language::join_words(&phrases_str) + ".\n"));
}
if let Some(liq_data) = item
.static_data()
.and_then(|sd| sd.liquid_container_data.as_ref())
{
match item.liquid_details.as_ref() {
Some(LiquidDetails { contents, .. }) if !contents.is_empty() => {
let total_volume: u64 = contents.iter().map(|c| c.1.clone()).sum();
let vol_frac = (total_volume as f64) / (liq_data.capacity as f64);
if vol_frac >= 0.99 {
contents_desc.push_str("It's full to the top with");
} else if vol_frac >= 0.75 {
contents_desc.push_str("It's nearly completely full of");
} else if vol_frac > 0.6 {
contents_desc.push_str("It's more than half full of");
} else if vol_frac > 0.4 {
contents_desc.push_str("It's about half full of");
} else if vol_frac > 0.29 {
contents_desc.push_str("It's about a third full of");
} else if vol_frac > 0.22 {
contents_desc.push_str("It's about a quarter full of");
} else {
contents_desc.push_str("It contains a tiny bit of");
}
contents_desc.push_str(" ");
let mut it = contents.iter();
let f1_opt = it.next();
let f2_opt = it.next();
match (f1_opt, f2_opt) {
(Some((&LiquidType::Water, _)), None) => contents_desc.push_str("water"),
_ => contents_desc.push_str("mixed fluids"),
}
contents_desc.push_str(".\n");
}
_ => contents_desc.push_str("It's completely dry.\n"),
}
}
let anything_worn = items
.iter()
.any(|it| it.action_type == LocationActionType::Worn);
if anything_worn {
let mut any_part_text = false;
let mut seen_clothes: BTreeSet<String> = BTreeSet::new();
for part in species_info_map()
.get(&item.species)
.map(|s| s.body_parts.clone())
.unwrap_or_else(|| vec![])
{
if let Some((top_item, covering_parts)) = items
.iter()
.filter_map(|it| {
if it.action_type != LocationActionType::Worn {
None
} else {
it.possession_type
.as_ref()
.and_then(|pt| possession_data().get(&pt))
.and_then(|pd| pd.wear_data.as_ref())
.and_then(|wd| {
if wd.covers_parts.contains(&part) {
Some((it, wd.covers_parts.clone()))
} else {
None
}
})
}
})
.filter_map(|(it, parts)| it.action_type_started.map(|st| ((it, parts), st)))
.max_by_key(|(_it, st)| st.clone())
.map(|(it, _)| it)
{
any_part_text = true;
let display = top_item.display_for_sentence(1, false);
if !seen_clothes.contains(&display) {
seen_clothes.insert(display.clone());
contents_desc.push_str(&format!(
"On {} {}, you see {}. ",
&item.pronouns.possessive,
&language::join_words(
&covering_parts
.iter()
.map(|p| p.display(None))
.collect::<Vec<&'static str>>()
),
&display
));
}
} else { } else {
buf.push_str(room::room_map_by_zloc() any_part_text = true;
.get(&(&room.zone, &room::GridCoords { x, y, z: my_loc.z })) contents_desc.push_str(&format!(
.map(|r| if room.zone == r.zone { "{} {} {} completely bare. ",
r.short &language::caps_first(&item.pronouns.possessive),
} else { part.display(item.sex.clone()),
r.secondary_zones.iter() part.copula(item.sex.clone())
.find(|sz| sz.zone == room.zone) ));
.map(|sz| sz.short)
.expect("Secondary zone missing")
})
.unwrap_or(" "));
} }
} }
buf.push('\n'); if any_part_text {
contents_desc.push_str("\n");
}
} }
buf
}
pub async fn describe_normal_item(ctx: &VerbContext<'_>, item: &Item) -> UResult<()> { let health_max = max_health(&item);
ctx.trans.queue_for_session( if health_max > 0 {
ctx.session, let health_ratio = (item.health as f64) / (health_max as f64);
Some(&format!("{}\n{}\n", if item.item_type == "player" || item.item_type == "npc" {
&item.display_for_session(&ctx.session_dat), if health_ratio == 1.0 {
item.details_for_session(&ctx.session_dat).unwrap_or("") contents_desc.push_str(&format!(
)) "{} is in perfect health.\n",
).await?; &language::caps_first(&item.pronouns.subject)
));
} else if health_ratio >= 0.75 {
contents_desc.push_str(&format!(
"{} has some minor cuts and bruises.\n",
&language::caps_first(&item.pronouns.subject)
));
} else if health_ratio >= 0.5 {
contents_desc.push_str(&format!(
"{} has deep wounds all over {} body.\n",
&language::caps_first(&item.pronouns.subject),
&item.pronouns.possessive
));
} else if health_ratio >= 0.25 {
contents_desc.push_str(&format!(
"{} looks seriously injured.\n",
&language::caps_first(&item.pronouns.subject)
));
} else {
contents_desc.push_str(&format!(
"{} looks like {}'s on death's door.\n",
&language::caps_first(&item.pronouns.subject),
&item.pronouns.possessive
));
}
if item
.active_effects
.iter()
.any(|e| e.0 == EffectType::Bandages)
{
contents_desc.push_str(&format!(
"{} is wrapped up in bandages.\n",
&language::caps_first(&item.pronouns.subject)
));
}
} else if item.item_type == "possession" {
if health_ratio == 1.0 {
contents_desc.push_str(&format!(
"{}'s in perfect condition.\n",
&language::caps_first(&item.pronouns.subject)
));
} else if health_ratio >= 0.75 {
contents_desc.push_str(&format!(
"{}'s slightly beaten up.\n",
&language::caps_first(&item.pronouns.subject)
));
} else if health_ratio >= 0.5 {
contents_desc.push_str(&format!(
"{}'s pretty beaten up.\n",
&language::caps_first(&item.pronouns.subject)
));
} else if health_ratio >= 0.25 {
contents_desc.push_str(&format!(
"{}'s seriously damaged.\n",
&language::caps_first(&item.pronouns.subject)
));
} else {
contents_desc.push_str(&format!(
"{}'s nearly completely destroyed.\n",
&language::caps_first(&item.pronouns.subject)
));
}
}
}
if item.item_type == "possession" {
if let Some(poss_data) = item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(&pt))
{
if let Some(describer) = poss_data.computed_extra_details {
contents_desc.push_str(
&describer
.describe_for(&ctx.trans, item, player_item)
.await?,
);
}
if let Some(charge_data) = poss_data.charge_data.as_ref() {
let unit = if item.charges == 1 {
charge_data.charge_name_prefix.to_owned() + " " + charge_data.charge_name_suffix
} else {
language::pluralise(charge_data.charge_name_prefix)
+ " "
+ charge_data.charge_name_suffix
};
contents_desc.push_str(&format!("It has {} {} left.\n", item.charges, unit));
}
}
if let Some(recipe_craft_data) = item
.possession_type
.as_ref()
.and_then(|pt| recipe_craft_by_recipe().get(pt))
{
contents_desc.push_str("You will need:\n");
for (input_pt, count) in &recipe_craft_data.craft_data.inputs.iter().counts() {
if let Some(pd) = possession_data().get(&input_pt) {
let thing = pd.display;
contents_desc.push_str(&format!(
" {} {}\n",
count,
&(if count != &1 {
language::pluralise(thing)
} else {
thing.to_owned()
})
));
}
}
match recipe_craft_data.bench.as_ref() {
None => contents_desc.push_str("You can make this without any special bench.\n"),
Some(bench) => {
if let Some(pd) = possession_data().get(bench) {
contents_desc
.push_str(&format!("You'll need to make this on a {}.\n", pd.display))
}
}
}
let diff = calc_level_gap(
&player_item,
&recipe_craft_data.craft_data.skill,
recipe_craft_data.craft_data.difficulty,
);
let challenge_level = if diff > 5.0 {
"You are rather unlikely to succeed in making this."
} else if diff >= 4.0 {
"You're not that likely to succeed in making this, and you're likely to be too confused to learn anything making it."
} else if diff >= 3.0 {
"You've got about a 1/4 chance to succeed at making this, and you might learn something making it."
} else if diff >= 2.0 {
"You've got about a 1/3 chance to succeed at making this, and you might learn something making it."
} else if diff >= 0.0 {
"You've got a less than 50/50 chance to succeed at making this, and you'll probably learn a lot."
} else if diff >= -2.0 {
"You've got a better than 50/50 chance to succeed at making this, and you'll probably learn a lot."
} else if diff >= -3.0 {
"Three out of four times, you'll succeed at making this, and you might still learn something."
} else if diff >= -4.0 {
"Most of the time, you'll succeed at making this, but you'll only rarely learn something new."
} else {
"You're highly likely to succeed at making this, but unlikely to learn anything new."
};
contents_desc.push_str(&format!("{}\n", challenge_level));
}
}
ctx.trans
.queue_for_session(
ctx.session,
Some(&format!(
"{}\n{}\n{}",
&item.display_for_sentence(1, false),
&item.details.as_ref().map(|d| d.as_str()).unwrap_or(""),
contents_desc,
)),
)
.await?;
Ok(()) Ok(())
} }
fn exits_for(room: &room::Room) -> String { fn exits_for(room: &room::Room) -> String {
let exit_text: Vec<String> = let exit_text: Vec<String> = room
room.exits.iter().map(|ex| format!(ansi!("<yellow>{}"), .exits
ex.direction.describe())).collect(); .iter()
format!(ansi!("<cyan>[ Exits: <bold>{} <reset><cyan>]<reset>"), exit_text.join(" ")) .map(|ex| {
format!(
"{}{}",
if ex.exit_climb.is_some() {
ansi!("<red>^")
} else {
ansi!("<yellow>")
},
ex.direction.describe()
)
})
.collect();
format!(
ansi!("<cyan>[ Exits: <bold>{} <reset><cyan>]<reset>"),
exit_text.join(" ")
)
} }
pub async fn describe_room(ctx: &VerbContext<'_>, item: &Item, fn exits_for_dyn(dynroom: &dynzone::Dynroom) -> String {
room: &room::Room, contents: &str) -> UResult<()> { let exit_text: Vec<String> = dynroom
let zone = room::zone_details().get(room.zone).map(|z|z.display).unwrap_or("Outside of time"); .exits
ctx.trans.queue_for_session( .iter()
ctx.session, .map(|ex| format!(ansi!("<yellow>{}"), ex.direction.describe()))
Some(&flow_around(&render_map(room, 5, 5), 10, ansi!("<reset> "), .collect();
&word_wrap(&format!(ansi!("<yellow>{}<reset> (<blue>{}<reset>)\n{}.{}\n{}\n"), format!(
item.display_for_session(&ctx.session_dat), ansi!("<cyan>[ Exits: <bold>{} <reset><cyan>]<reset>"),
zone, exit_text.join(" ")
item.details_for_session( )
&ctx.session_dat).unwrap_or(""), }
contents, exits_for(room)),
|row| if row >= 5 { 80 } else { 68 }), 68)) pub async fn is_room_illuminated(
).await?; ctx: &VerbContext<'_>,
player_item: &Item,
location: &Item,
room: &room::Room,
) -> UResult<bool> {
let consider_exits: Vec<ObjectRef> = room
.exits
.iter()
.filter_map(|ex| room::resolve_exit(room, ex))
// Do we want to consider skipping exits behind closed doors?
.map(|r| ObjectRef {
item_type: "room".to_owned(),
item_code: r.code.to_owned(),
})
.collect();
Ok(ctx
.trans
.is_illuminated(player_item, location, &consider_exits)
.await?)
}
pub async fn describe_room(
ctx: &VerbContext<'_>,
player_item: &Item,
item: &Item,
room: &room::Room,
contents: &str,
) -> UResult<()> {
let zone = room::zone_details()
.get(room.zone.as_str())
.map(|z| z.display)
.unwrap_or("Outside of time");
let illum = is_room_illuminated(ctx, player_item, item, room).await?;
let desc = flow_around(
&render_map(room, 5, 5),
10,
ansi!("<reset> "),
&word_wrap(
&format!(
ansi!("<yellow>{}<reset> (<blue>{}<reset>)\n{}{}.{}\n{}\n"),
item.display_for_sentence(1, false),
zone,
&item.details.as_ref().map(|d| d.as_str()).unwrap_or(""),
item.details_dyn_suffix
.as_ref()
.map(|d| d.as_str())
.unwrap_or(""),
contents,
exits_for(room)
),
|row| if row >= 5 { 80 } else { 68 },
),
68,
);
ctx.trans
.queue_for_session(
ctx.session,
Some(if illum {
&desc
} else {
"It's too dark to see much.\n"
}),
)
.await?;
Ok(()) Ok(())
} }
async fn list_item_contents<'l>(ctx: &'l VerbContext<'_>, item: &'l Item) -> UResult<String> { pub async fn describe_dynroom(
ctx: &VerbContext<'_>,
item: &Item,
dynzone: &dynzone::Dynzone,
dynroom: &dynzone::Dynroom,
contents: &str,
) -> UResult<()> {
ctx.trans
.queue_for_session(
ctx.session,
Some(&flow_around(
&render_map_dyn(dynzone, dynroom, 5, 5),
10,
ansi!("<reset> "),
&word_wrap(
&format!(
ansi!("<yellow>{}<reset> (<blue>{}<reset>)\n{}.{}\n{}\n"),
item.display_for_sentence(1, false),
dynzone.zonename,
&item.details.as_ref().map(|d| d.as_str()).unwrap_or(""),
contents,
exits_for_dyn(dynroom)
),
|row| if row >= 5 { 80 } else { 68 },
),
68,
)),
)
.await?;
Ok(())
}
async fn describe_door(
ctx: &VerbContext<'_>,
room_item: &Item,
state: &DoorState,
direction: &Direction,
) -> UResult<()> {
let mut msg = format!("That exit is blocked by {}.", &state.description);
if let Some(lock) = ctx
.trans
.find_by_action_and_location(
&room_item.refstr(),
&LocationActionType::InstalledOnDoorAsLock((*direction).clone()),
)
.await?
.first()
{
let lock_desc = lock.display_for_sentence(1, false);
msg.push_str(&format!(" The door is locked with {}", &lock_desc));
}
msg.push('\n');
ctx.trans.queue_for_session(ctx.session, Some(&msg)).await?;
Ok(())
}
async fn list_room_contents<'l>(ctx: &'l VerbContext<'_>, item: &'l Item) -> UResult<String> {
if item.flags.contains(&ItemFlag::NoSeeContents) { if item.flags.contains(&ItemFlag::NoSeeContents) {
return Ok(" It is too foggy to see who or what else is here.".to_owned()); return Ok(" It is too foggy to see who or what else is here.".to_owned());
} }
let mut buf = String::new(); let mut buf = String::new();
let mut items = ctx.trans.find_items_by_location(&format!("{}/{}", let mut items = ctx
item.item_type, item.item_code)).await?; .trans
.find_items_by_location(&format!("{}/{}", item.item_type, item.item_code))
.await?;
items.sort_unstable_by(|it1, it2| (&it1.display).cmp(&it2.display)); items.sort_unstable_by(|it1, it2| (&it1.display).cmp(&it2.display));
let all_groups: Vec<Vec<&Arc<Item>>> = items let all_groups: Vec<Vec<&Arc<Item>>> = items
.iter() .iter()
.group_by(|i| i.display_for_sentence(true, 1, false)) .filter(|i| {
i.action_type.is_visible_in_look() && !i.flags.contains(&ItemFlag::DontListInLook)
})
.group_by(|i| i.display_for_sentence(1, false))
.into_iter() .into_iter()
.map(|(_, g)|g.collect::<Vec<&Arc<Item>>>()) .map(|(_, g)| g.collect::<Vec<&Arc<Item>>>())
.collect::<Vec<Vec<&Arc<Item>>>>(); .collect::<Vec<Vec<&Arc<Item>>>>();
for group_items in all_groups { for group_items in all_groups {
let head = &group_items[0]; let head = &group_items[0];
let is_creature = head.item_type == "player" || head.item_type.starts_with("npc"); let is_creature = head.item_type == "player" || head.item_type.starts_with("npc");
buf.push(' '); buf.push(' ');
buf.push_str(&head.display_for_sentence(!ctx.session_dat.less_explicit_mode, buf.push_str(&head.display_for_sentence(group_items.len(), true));
group_items.len(), true)); buf.push_str(if group_items.len() > 1 {
buf.push_str(if group_items.len() > 1 { " are " } else { " is "}); " are "
} else {
" is "
});
match head.action_type { match head.action_type {
LocationActionType::Sitting => buf.push_str("sitting "), LocationActionType::Sitting(ref on) => {
LocationActionType::Reclining => buf.push_str("reclining "), buf.push_str("sitting ");
LocationActionType::Normal | LocationActionType::Attacking(_) if is_creature => if let Some((on_type, on_code)) =
buf.push_str("standing "), on.as_ref().and_then(|on_ref| on_ref.split_once("/"))
{
if let Some(sit_on) = ctx.trans.find_item_by_type_code(on_type, on_code).await?
{
buf.push_str("on ");
buf.push_str(&sit_on.display_for_sentence(1, false));
}
}
}
LocationActionType::Reclining(ref on) => {
buf.push_str("reclining ");
if let Some((on_type, on_code)) =
on.as_ref().and_then(|on_ref| on_ref.split_once("/"))
{
if let Some(sit_on) = ctx.trans.find_item_by_type_code(on_type, on_code).await?
{
buf.push_str("on ");
buf.push_str(&sit_on.display_for_sentence(1, false));
}
}
}
LocationActionType::Normal | LocationActionType::Attacking(_) if is_creature => {
if head.death_data.is_some() {
buf.push_str("lying ");
} else {
buf.push_str("standing ");
}
}
_ => {} _ => {}
} }
buf.push_str("here"); buf.push_str("here");
@ -110,20 +580,24 @@ async fn list_item_contents<'l>(ctx: &'l VerbContext<'_>, item: &'l Item) -> URe
Subattack::Feinting => buf.push_str(", feinting "), Subattack::Feinting => buf.push_str(", feinting "),
Subattack::Grabbing => buf.push_str(", grabbing "), Subattack::Grabbing => buf.push_str(", grabbing "),
Subattack::Wrestling => buf.push_str(", wrestling "), Subattack::Wrestling => buf.push_str(", wrestling "),
_ => buf.push_str(", attacking ") _ => buf.push_str(", attacking "),
} }
match &head.presence_target { match &head
.active_combat
.as_ref()
.and_then(|ac| ac.attacking.clone())
.or_else(|| head.presence_target.clone())
{
None => buf.push_str("someone"), None => buf.push_str("someone"),
Some(who) => match who.split_once("/") { Some(who) => match who.split_once("/") {
None => buf.push_str("someone"), None => buf.push_str("someone"),
Some((ttype, tcode)) => Some((ttype, tcode)) => {
match ctx.trans.find_item_by_type_code(ttype, tcode).await? { match ctx.trans.find_item_by_type_code(ttype, tcode).await? {
None => buf.push_str("someone"), None => buf.push_str("someone"),
Some(it) => buf.push_str( Some(it) => buf.push_str(&it.display_for_sentence(1, false)),
&it.display_for_session(&ctx.session_dat)
)
} }
} }
},
} }
} }
buf.push('.'); buf.push('.');
@ -131,38 +605,234 @@ async fn list_item_contents<'l>(ctx: &'l VerbContext<'_>, item: &'l Item) -> URe
Ok(buf) Ok(buf)
} }
pub async fn direction_to_item(
trans: &DBTrans,
use_location: &str,
direction: &Direction,
) -> UResult<Option<Arc<Item>>> {
// Firstly check dynamic exits, since they apply to rooms and dynrooms...
if let Some(dynroom_result) = trans.find_exact_dyn_exit(use_location, direction).await? {
return Ok(Some(Arc::new(dynroom_result)));
}
let (heretype, herecode) = use_location
.split_once("/")
.unwrap_or(("room", "repro_xv_chargen"));
if heretype == "dynroom" {
let old_dynroom_item = match trans.find_item_by_type_code(heretype, herecode).await? {
None => user_error("Your current room has vanished!".to_owned())?,
Some(v) => v,
};
let (dynzone_code, dynroom_code) = match old_dynroom_item.special_data.as_ref() {
Some(ItemSpecialData::DynroomData {
dynzone_code,
dynroom_code,
}) => (dynzone_code, dynroom_code),
_ => user_error("Your current room is invalid!".to_owned())?,
};
let dynzone = dynzone::dynzone_by_type()
.get(
&dynzone::DynzoneType::from_str(dynzone_code).ok_or_else(|| {
UserError("The type of your current zone no longer exists".to_owned())
})?,
)
.ok_or_else(|| {
UserError("The type of your current zone no longer exists".to_owned())
})?;
let dynroom = dynzone
.dyn_rooms
.get(dynroom_code.as_str())
.ok_or_else(|| UserError("Your current room type no longer exists".to_owned()))?;
let exit = dynroom
.exits
.iter()
.find(|ex| ex.direction == *direction)
.ok_or_else(|| UserError("There is nothing in that direction".to_owned()))?;
return match exit.target {
dynzone::ExitTarget::ExitZone => {
let (zonetype, zonecode) = old_dynroom_item
.location
.split_once("/")
.ok_or_else(|| UserError("Invalid zone for your room".to_owned()))?;
let zoneitem = trans
.find_item_by_type_code(zonetype, zonecode)
.await?
.ok_or_else(|| UserError("Can't find your zone".to_owned()))?;
let zone_exit = match zoneitem.special_data.as_ref() {
Some(ItemSpecialData::DynzoneData {
zone_exit: None, ..
}) => user_error("That exit doesn't seem to go anywhere".to_owned())?,
Some(ItemSpecialData::DynzoneData {
zone_exit: Some(zone_exit),
..
}) => zone_exit,
_ => user_error(
"The zone you are in has invalid data associated with it".to_owned(),
)?,
};
let (zone_exit_type, zone_exit_code) =
zone_exit.split_once("/").ok_or_else(|| {
UserError("Oops, that way out seems to be broken.".to_owned())
})?;
Ok(trans
.find_item_by_type_code(zone_exit_type, zone_exit_code)
.await?)
}
dynzone::ExitTarget::Intrazone { subcode } => {
let to_item = trans
.find_item_by_location_dynroom_code(&old_dynroom_item.location, &subcode)
.await?
.ok_or_else(|| {
UserError("Can't find the room in that direction.".to_owned())
})?;
Ok(Some(Arc::new(to_item)))
}
};
}
if heretype != "room" {
user_error("Navigating outside rooms not yet supported.".to_owned())?
}
let room = room::room_map_by_code()
.get(herecode)
.ok_or_else(|| UserError("Can't find your current location".to_owned()))?;
let exit = room
.exits
.iter()
.find(|ex| ex.direction == *direction)
.ok_or_else(|| UserError("There is nothing in that direction".to_owned()))?;
let new_room = room::resolve_exit(room, exit)
.ok_or_else(|| UserError("Can't find that room".to_owned()))?;
Ok(trans.find_item_by_type_code("room", &new_room.code).await?)
}
async fn describe_store_item(
ctx: &VerbContext<'_>,
player_item: &Item,
location_type: &str,
location_code: &str,
item: &str,
) -> UResult<()> {
if location_type != "room" {
user_error("Nothing is for sale here.".to_owned())?;
}
let room = room_map_by_code()
.get(location_code)
.ok_or_else(|| UserError("Couldn't find your room to look.".to_owned()))?;
if room.stock_list.is_empty() {
user_error("Nothing is for sale here.".to_owned())?
}
let (poss_type, _poss_data) = room
.stock_list
.iter()
.filter(|rs| rs.can_buy)
.filter_map(|rs| match possession_data().get(&rs.possession_type) {
Some(pd) => Some((rs.possession_type.clone(), pd)),
None => None,
})
.find(|(_, pd)| {
pd.display.starts_with(item) || pd.aliases.iter().any(|a| a.starts_with(item))
})
.ok_or_else(|| UserError("Couldn't find anything like that for sale.".to_owned()))?;
let tmp_item: Item = poss_type.clone().into();
describe_normal_item(&player_item, ctx, &tmp_item).await
}
pub struct Verb; pub struct Verb;
#[async_trait] #[async_trait]
impl UserVerb for Verb { impl UserVerb for Verb {
async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?; let player_item = get_player_item_or_fail(ctx).await?;
let rem_trim = remaining.trim().to_lowercase(); let mut rem_trim = remaining.trim().to_lowercase();
let (heretype, herecode) = player_item.location.split_once("/").unwrap_or(("room", "repro_xv_chargen")); let rem_orig = rem_trim.clone();
let item: Arc<Item> = if rem_trim == "" { if rem_trim.starts_with("in ") {
ctx.trans.find_item_by_type_code(heretype, herecode).await? rem_trim = rem_trim[3..].trim_start().to_owned();
.ok_or_else(|| UserError("Sorry, that no longer exists".to_owned()))? }
} else if let Some(dir) = Direction::parse(&rem_trim) { if rem_trim.starts_with("at ") {
if heretype != "room" { rem_trim = rem_trim[3..].trim_start().to_owned();
// Fix this when we have planes / boats / roomkits. }
user_error("Navigating outside rooms not yet supported.".to_owned())? let use_location = if player_item.death_data.is_some() {
} else { "room/repro_xv_respawn"
if let Some(room) = room::room_map_by_code().get(herecode) { } else {
match room.exits.iter().find(|ex| ex.direction == *dir) { &player_item.location
None => user_error("There is nothing in that direction".to_owned())?, };
Some(exit) => { let (heretype, herecode) = use_location
match room::resolve_exit(room, exit) { .split_once("/")
None => user_error("There is nothing in that direction".to_owned())?, .unwrap_or(("room", "repro_xv_chargen"));
Some(room2) =>
ctx.trans.find_item_by_type_code("room", room2.code).await?
.ok_or_else(|| UserError("Sorry, that no longer exists".to_owned()))?
} if rem_trim.ends_with(" for sale") {
} return Ok(describe_store_item(
ctx,
&player_item,
heretype,
herecode,
rem_trim[0..(rem_trim.len() - 9)].trim_end(),
)
.await?);
}
let item: Arc<Item> = if rem_trim == "" {
ctx.trans
.find_item_by_type_code(heretype, herecode)
.await?
.ok_or_else(|| UserError("Sorry, that no longer exists".to_owned()))?
} else if let Some(dir) =
Direction::parse(&rem_trim).or_else(|| Direction::parse(&rem_orig))
{
// This is complex because "in" is overloaded, and if this fails, we want
// to also consider if they are looking in a container.
match is_door_in_direction(&ctx.trans, &dir, use_location).await {
Ok(DoorSituation::NoDoor)
| Ok(DoorSituation::DoorOutOfRoom {
state: DoorState { open: true, .. },
..
})
| Ok(DoorSituation::DoorIntoRoom {
state: DoorState { open: true, .. },
..
})
| Err(UserError(_)) => {}
Ok(DoorSituation::DoorIntoRoom {
state,
room_with_door,
..
}) => {
if let Some(rev_dir) = dir.reverse() {
return describe_door(ctx, &room_with_door, &state, &rev_dir).await;
} }
} else {
user_error("Can't find your current location".to_owned())?
} }
Ok(DoorSituation::DoorOutOfRoom {
state,
room_with_door,
..
}) => {
return describe_door(ctx, &room_with_door, &state, &dir).await;
}
Err(e) => Err(e)?,
}
match direction_to_item(&ctx.trans, use_location, &dir).await {
Ok(Some(item)) => item,
Ok(None) | Err(UserError(_)) => search_item_for_user(
&ctx,
&ItemSearchParams {
include_contents: true,
include_loc_contents: true,
limit: 1,
..ItemSearchParams::base(&player_item, &rem_trim)
},
)
.await
.map_err(|e| match e {
UserError(_) => UserError("There's nothing in that direction".to_owned()),
e => e,
})?,
Err(e) => Err(e)?,
} }
} else if rem_trim == "me" || rem_trim == "self" { } else if rem_trim == "me" || rem_trim == "self" {
player_item.clone() player_item.clone()
@ -172,20 +842,49 @@ impl UserVerb for Verb {
&ItemSearchParams { &ItemSearchParams {
include_contents: true, include_contents: true,
include_loc_contents: true, include_loc_contents: true,
limit: 1,
..ItemSearchParams::base(&player_item, &rem_trim) ..ItemSearchParams::base(&player_item, &rem_trim)
} },
).await? )
.await?
}; };
if item.item_type != "room" { if item.item_type == "room" {
describe_normal_item(ctx, &item).await?; let room = room::room_map_by_code()
} else { .get(item.item_code.as_str())
let room =
room::room_map_by_code().get(item.item_code.as_str())
.ok_or_else(|| UserError("Sorry, that room no longer exists".to_owned()))?; .ok_or_else(|| UserError("Sorry, that room no longer exists".to_owned()))?;
describe_room(ctx, &item, &room, &list_item_contents(ctx, &item).await?).await?; describe_room(
ctx,
&player_item,
&item,
&room,
&list_room_contents(ctx, &item).await?,
)
.await?;
} else if item.item_type == "dynroom" {
let (dynzone, dynroom) = match &item.special_data {
Some(ItemSpecialData::DynroomData {
dynzone_code,
dynroom_code,
}) => dynzone::DynzoneType::from_str(dynzone_code.as_str())
.and_then(|dz_t| dynzone::dynzone_by_type().get(&dz_t))
.and_then(|dz| dz.dyn_rooms.get(dynroom_code.as_str()).map(|dr| (dz, dr)))
.ok_or_else(|| UserError("Dynamic room doesn't exist anymore.".to_owned()))?,
_ => user_error("Expected dynroom to have DynroomData".to_owned())?,
};
describe_dynroom(
ctx,
&item,
&dynzone,
&dynroom,
&list_room_contents(ctx, &item).await?,
)
.await?;
} else {
describe_normal_item(&player_item, ctx, &item).await?;
} }
Ok(()) Ok(())
} }
} }
static VERB_INT: Verb = Verb; static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef; pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,417 @@
use super::{
get_player_item_or_fail, search_item_for_user, user_error, ItemSearchParams, UResult,
UserError, UserVerb, UserVerbRef, VerbContext,
};
use crate::{
models::item::{Item, ItemFlag},
regular_tasks::queued_command::{
queue_command_and_save, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::{
comms::broadcast_to_room,
destroy_container,
skills::{crit_fail_penalty_for_skill, skill_check_and_grind},
urges::change_stress_considering_cool,
},
static_content::possession_type::{
possession_data, recipe_craft_by_recipe, CraftData, PossessionType,
},
};
use ansi::ansi;
use async_trait::async_trait;
use std::time;
use std::{collections::BTreeSet, sync::Arc};
// This is written this way for future expansion to dynamic recipes.
async fn get_craft_data_for_instructions<'l>(instructions: &'l Item) -> UResult<Option<CraftData>> {
// For now, only static recipes, so we just fetch them...
Ok(instructions
.possession_type
.as_ref()
.and_then(|pt| recipe_craft_by_recipe().get(pt))
.map(|rcd| rcd.craft_data.clone()))
}
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error("The dead aren't very good at making stuff.".to_owned())?;
}
if ctx.item.urges.as_ref().map(|u| u.stress.value).unwrap_or(0) > 7000 {
user_error(
ansi!(
"You are too tired and stressed to consider crafts. Maybe try to \
<bold>sit<reset> or <bold>recline<reset> for a bit!"
)
.to_owned(),
)?;
}
let (bench_id_opt, instructions_id) = match ctx.command {
QueueCommand::Make {
ref bench_possession_id,
ref instructions_possession_id,
..
} => (
bench_possession_id.as_ref().map(|s| s.as_str()),
instructions_possession_id,
),
_ => user_error("Unexpected command".to_owned())?,
};
let (expected_location, bench_opt) = match bench_id_opt {
None => (ctx.item.location.clone(), None),
Some(bench_id) => {
let bench = ctx
.trans
.find_item_by_type_code("possession", bench_id)
.await?
.ok_or_else(|| {
UserError(
"Hmm, you can't find the equipment you were planning to use!"
.to_owned(),
)
})?;
if bench.location != ctx.item.location {
user_error(
"Hmm, you can't find the equipment you were planning to use!".to_owned(),
)?;
}
(bench.refstr(), Some(bench))
}
};
let instructions = ctx
.trans
.find_item_by_type_code("possession", instructions_id)
.await?
.ok_or_else(|| {
UserError(
"Hmm, you can't find the instructions you were planning to follow!".to_owned(),
)
})?;
if instructions.location != expected_location {
user_error(
"Hmm, you can't find the instructions you were planning to follow!".to_owned(),
)?;
}
let mut msg = format!(
"{} starts fiddling around trying to make something",
&ctx.item.display_for_sentence(1, true)
);
match bench_opt {
None => {}
Some(bench) => {
msg.push_str(&format!(" on {}", bench.display_for_sentence(1, false)));
}
}
msg.push_str(".\n");
broadcast_to_room(&ctx.trans, &ctx.item.location, None, &msg).await?;
Ok(time::Duration::from_secs(1))
}
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
let (bench_id_opt, instructions_id, already_used) = match ctx.command {
QueueCommand::Make {
ref bench_possession_id,
ref instructions_possession_id,
ref already_used,
} => (
bench_possession_id.as_ref().map(|s| s.as_str()),
instructions_possession_id,
already_used,
),
_ => user_error("Unexpected command".to_owned())?,
};
let (expected_location, bench_opt) = match bench_id_opt {
None => (ctx.item.location.clone(), None),
Some(bench_id) => {
let bench = ctx
.trans
.find_item_by_type_code("possession", bench_id)
.await?
.ok_or_else(|| {
UserError(
"Hmm, you can't find the equipment you were planning to use!"
.to_owned(),
)
})?;
if bench.location != ctx.item.location {
user_error(
"Hmm, you can't find the equipment you were planning to use!".to_owned(),
)?;
}
(bench.refstr(), Some(bench))
}
};
let instructions = ctx
.trans
.find_item_by_type_code("possession", instructions_id)
.await?
.ok_or_else(|| {
UserError(
"Hmm, you can't find the instructions you were planning to follow!".to_owned(),
)
})?;
if instructions.location != expected_location {
user_error(
"Hmm, you can't find the instructions you were planning to follow!".to_owned(),
)?;
}
if let Some(bench) = bench_opt.as_ref() {
if let Some(bench_data) = bench
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
.and_then(|pd| pd.bench_data)
{
bench_data
.check_make(&ctx.trans, bench, &instructions)
.await?;
}
}
let on_what = match bench_opt {
None => "".to_owned(),
Some(bench) => format!(" on {}", bench.display_for_sentence(1, false)),
};
let craft_data = get_craft_data_for_instructions(&instructions)
.await?
.ok_or_else(|| UserError("Looks like you can't make that anymore.".to_owned()))?;
let mut ingredients_left: Vec<PossessionType> = craft_data.inputs.clone();
let mut to_destroy_if_success: Vec<Arc<Item>> = Vec::new();
for item_id in already_used.iter() {
let item = ctx
.trans
.find_item_by_type_code("possession", &item_id)
.await?
.ok_or_else(|| UserError("Item used in crafting not found.".to_owned()))?;
to_destroy_if_success.push(item.clone());
let possession_type = item
.possession_type
.as_ref()
.ok_or_else(|| UserError("Item used in crafting not a possession.".to_owned()))?;
if let Some(match_pos) = ingredients_left.iter().position(|pt| pt == possession_type) {
ingredients_left.remove(match_pos);
}
}
let session = if ctx.item.item_type == "player" {
ctx.trans
.find_session_for_player(&ctx.item.item_code)
.await?
} else {
None
};
match ingredients_left.iter().next() {
None => {
for item in to_destroy_if_success {
destroy_container(&ctx.trans, &item).await?;
}
let mut new_item: Item = craft_data.output.clone().into();
new_item.item_code = ctx.trans.alloc_item_code().await?.to_string();
new_item.location = expected_location.clone();
ctx.trans.create_item(&new_item).await?;
broadcast_to_room(
&ctx.trans,
&ctx.item.location,
None,
&format!(
"{} makes a {}{}.\n",
&ctx.item.display_for_sentence(1, true),
&new_item.display_for_sentence(1, false),
&on_what
),
)
.await?;
}
Some(possession_type) => {
let addable = ctx
.trans
.find_items_by_location_possession_type_excluding(
expected_location.as_str(),
possession_type,
&already_used.iter().map(|v| v.as_str()).collect(),
)
.await?;
let pd = possession_data().get(&possession_type).ok_or_else(|| {
UserError(
"Looks like something needed to make that is something I know nothing about!".to_owned(),
)
})?;
match addable.iter().next() {
None => user_error(format!("You realise you'd need {}.", pd.display))?,
Some(item) => {
let skill_result = skill_check_and_grind(
&ctx.trans,
ctx.item,
&craft_data.skill,
craft_data.difficulty,
)
.await?;
if skill_result <= -0.5 {
crit_fail_penalty_for_skill(&ctx.trans, ctx.item, &craft_data.skill)
.await?;
ctx.trans
.delete_item(&item.item_type, &item.item_code)
.await?;
change_stress_considering_cool(&ctx.trans, &mut ctx.item, 1000).await?;
if let Some((sess, _)) = session {
ctx.trans
.queue_for_session(
&sess,
Some(&format!(
"You try adding {}, but it goes badly and you waste it.\n",
&item.display_for_sentence(1, false)
)),
)
.await?;
}
} else if skill_result <= 0.0 {
change_stress_considering_cool(&ctx.trans, &mut ctx.item, 500).await?;
if let Some((sess, _)) = session {
ctx.trans
.queue_for_session(
&sess,
Some(&format!(
"You try and fail at adding {}.\n",
&item.display_for_sentence(1, false)
)),
)
.await?;
}
} else {
if let Some((sess, _)) = session {
ctx.trans
.queue_for_session(
&sess,
Some(&format!(
"You try adding {}.\n",
&item.display_for_sentence(1, false),
)),
)
.await?;
}
let mut new_already_used = (*already_used).clone();
new_already_used.insert(item.item_code.clone());
ctx.item.queue.push_front(QueueCommand::Make {
bench_possession_id: bench_id_opt.map(|id| id.to_owned()),
instructions_possession_id: instructions_id.to_string(),
already_used: new_already_used,
});
}
}
}
}
}
Ok(())
}
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let rtrim = remaining.trim();
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error("The dead aren't very good at making stuff.".to_owned())?;
}
let (bench, output) = match rtrim.split_once(" on ") {
None => (None, rtrim),
Some((output_str, bench_str)) => {
let bench = search_item_for_user(
ctx,
&ItemSearchParams {
item_type_only: Some("possession"),
include_loc_contents: true,
..ItemSearchParams::base(&player_item, bench_str.trim())
},
)
.await?;
(Some(bench), output_str.trim())
}
};
let instructions = search_item_for_user(
ctx,
&ItemSearchParams {
item_type_only: Some("possession"),
include_contents: true,
flagged_only: Some(ItemFlag::Instructions),
..ItemSearchParams::base(bench.as_ref().unwrap_or(&player_item), output.trim())
},
)
.await?;
let recipe_craft = instructions
.possession_type
.as_ref()
.and_then(|pt| recipe_craft_by_recipe().get(&pt))
.ok_or_else(|| {
UserError(
"Sorry, those instructions no longer seem to form part of the game!".to_owned(),
)
})?;
match (recipe_craft.bench.as_ref(), bench.as_ref()) {
(Some(bench_type), None) => user_error(format!(
"The {} can only be made on the {}.",
&instructions.display_for_sentence(1, false),
possession_data()
.get(bench_type)
.map(|pd| pd.display)
.unwrap_or("bench")
))?,
(Some(bench_type), Some(bench))
if bench.possession_type.as_ref() != Some(bench_type) =>
{
user_error(format!(
"The {} can only be made on the {}.",
&instructions.display_for_sentence(1, false),
possession_data()
.get(bench_type)
.map(|pd| pd.display)
.unwrap_or("bench")
))?
}
_ => {}
}
queue_command_and_save(
ctx,
&player_item,
&QueueCommand::Make {
bench_possession_id: bench.as_ref().map(|b| b.item_code.clone()),
instructions_possession_id: instructions.item_code.clone(),
already_used: BTreeSet::<String>::new(),
},
)
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -1,15 +1,133 @@
use super::{VerbContext, UserVerb, UserVerbRef, UResult, UserError, user_error, use super::{
get_player_item_or_fail}; get_player_item_or_fail, look::is_room_illuminated, user_error, UResult, UserError, UserVerb,
use async_trait::async_trait; UserVerbRef, VerbContext,
use ansi::{ansi, flow_around};
use crate::{
models::item::Item,
static_content::room::{self, Direction}
}; };
use crate::{
models::item::{Item, ItemSpecialData},
static_content::{
dynzone,
room::{self, Direction, GridCoords},
},
};
use ansi::{ansi, flow_around};
use async_trait::async_trait;
use std::sync::Arc; use std::sync::Arc;
pub fn render_lmap(room: &room::Room, width: usize, height: usize, pub fn render_map(room: &room::Room, width: usize, height: usize) -> String {
captions_needed: &mut Vec<(usize, &'static str, &'static str)>) -> String { let mut buf = String::new();
let my_loc = &room.grid_coords;
let min_x = my_loc.x - (width as i64) / 2;
let max_x = min_x + (width as i64);
let min_y = my_loc.y - (height as i64) / 2;
let max_y = min_y + (height as i64);
for y in min_y..max_y {
for x in min_x..max_x {
if my_loc.x == x && my_loc.y == y {
buf.push_str(ansi!("<bgblue><red>()<reset>"))
} else {
buf.push_str(
&room::room_map_by_zloc()
.get(&(&room.zone, &room::GridCoords { x, y, z: my_loc.z }))
.map(|r| {
if room.zone == r.zone {
r.short.as_str()
} else {
r.secondary_zones
.iter()
.find(|sz| sz.zone == room.zone)
.map(|sz| sz.short.as_str())
.expect("Secondary zone missing")
}
})
.unwrap_or(" "),
);
}
}
buf.push('\n');
}
buf
}
pub fn render_map_dyn(
dynzone: &dynzone::Dynzone,
dynroom: &dynzone::Dynroom,
width: usize,
height: usize,
) -> String {
let mut buf = String::new();
let my_loc = &dynroom.grid_coords;
let min_x = my_loc.x - (width as i64) / 2;
let max_x = min_x + (width as i64);
let min_y = my_loc.y - (height as i64) / 2;
let max_y = min_y + (height as i64);
let main_exit: Option<GridCoords> = dynzone
.dyn_rooms
.iter()
.flat_map(|(_, dr)| {
dr.exits
.iter()
.filter(|ex| match ex.target {
dynzone::ExitTarget::ExitZone => true,
_ => false,
})
.map(|ex| dr.grid_coords.apply(&ex.direction))
})
.next();
for y in min_y..max_y {
for x in min_x..max_x {
if my_loc.x == x && my_loc.y == y {
buf.push_str(ansi!("<bgblue><red>()<reset>"))
} else {
buf.push_str(
dynzone
.dyn_rooms
.iter()
.find(|(_, dr)| {
dr.grid_coords.x == x
&& dr.grid_coords.y == y
&& dr.grid_coords.z == my_loc.z
})
.map(|(_, r)| r.short)
.or_else(|| {
main_exit.as_ref().and_then(|ex_pos| {
if ex_pos.x == x && ex_pos.y == y && ex_pos.z == my_loc.z {
Some("<<")
} else {
None
}
})
})
.unwrap_or(" "),
);
}
}
buf.push('\n');
}
buf
}
fn has_exit_to_dir_in_zone(room: &Option<&&room::Room>, zone: &str, direction: &Direction) -> bool {
match room.as_ref().and_then(|r| {
r.exits
.iter()
.find(|ex| ex.direction == *direction)
.and_then(|ex| room::resolve_exit(r, ex))
}) {
None => false,
Some(other_room) => {
other_room.zone == zone || other_room.secondary_zones.iter().any(|z| z.zone == zone)
}
}
}
pub fn render_lmap(
room: &room::Room,
width: usize,
height: usize,
captions_needed: &mut Vec<(usize, &'static str, &'static str)>,
) -> String {
let mut buf = String::new(); let mut buf = String::new();
let my_loc = &room.grid_coords; let my_loc = &room.grid_coords;
let min_x = my_loc.x - (width as i64) / 2; let min_x = my_loc.x - (width as i64) / 2;
@ -19,67 +137,81 @@ pub fn render_lmap(room: &room::Room, width: usize, height: usize,
for y in min_y..max_y { for y in min_y..max_y {
for x in min_x..max_x { for x in min_x..max_x {
let coord = room::GridCoords { x, y, z: my_loc.z }; let coord = room::GridCoords { x, y, z: my_loc.z };
let coord_room = room::room_map_by_zloc() let coord_room = room::room_map_by_zloc().get(&(&room.zone, &coord));
.get(&(&room.zone, &coord));
if my_loc.x == x && my_loc.y == y { if my_loc.x == x && my_loc.y == y {
buf.push_str(ansi!("<bgblue><red> () <reset>")) buf.push_str(ansi!("<bgblue><red> () <reset>"))
} else { } else {
let code_capt_opt = coord_room.map( let code_capt_opt = coord_room.map(|r| {
|r| if room.zone == r.zone { if room.zone == r.zone {
(r.short, if r.should_caption { (
Some((r.name, ((my_loc.x as i64 - r.grid_coords.x).abs() + r.short.as_str(),
(my_loc.y as i64 - r.grid_coords.y).abs() if r.should_caption {
) as usize)) } else { None }) Some((
r.name.as_str(),
((my_loc.x as i64 - r.grid_coords.x).abs()
+ (my_loc.y as i64 - r.grid_coords.y).abs())
as usize,
))
} else {
None
},
)
} else { } else {
r.secondary_zones.iter() r.secondary_zones
.iter()
.find(|sz| sz.zone == room.zone) .find(|sz| sz.zone == room.zone)
.map(|sz| (sz.short, sz.caption.map( .map(|sz| {
|c| (c, ((my_loc.x as i64 - r.grid_coords.x).abs() + (
(my_loc.y as i64 - r.grid_coords.y).abs()) sz.short.as_str(),
as usize)))) sz.caption.as_ref().map(|c| {
(
c.as_str(),
((my_loc.x as i64 - r.grid_coords.x).abs()
+ (my_loc.y as i64 - r.grid_coords.y).abs())
as usize,
)
}),
)
})
.expect("Secondary zone missing") .expect("Secondary zone missing")
}); }
});
match code_capt_opt { match code_capt_opt {
None => buf.push_str(" "), None => buf.push_str(" "),
Some((code, capt_opt)) => { Some((code, capt_opt)) => {
if let Some((capt, closeness)) = capt_opt { if let Some((capt, closeness)) = capt_opt {
captions_needed.push((closeness, code, capt)); captions_needed.push((closeness, &code, &capt));
} }
buf.push('['); buf.push('[');
buf.push_str(code); buf.push_str(&code);
buf.push(']'); buf.push(']');
} }
} }
} }
match coord_room.and_then( if has_exit_to_dir_in_zone(&coord_room, &room.zone, &Direction::EAST) {
|r| r.exits.iter().find(|ex| ex.direction == Direction::EAST)) { buf.push('-')
None => buf.push(' '), } else {
Some(_) => buf.push('-') buf.push(' ')
} }
} }
buf.push('\n');
for x in min_x..max_x { for x in min_x..max_x {
let mut coord = room::GridCoords { x, y, z: my_loc.z }; let mut coord = room::GridCoords { x, y, z: my_loc.z };
let coord_room = room::room_map_by_zloc() let coord_room = room::room_map_by_zloc().get(&(&room.zone, &coord));
.get(&(&room.zone, &coord)); if has_exit_to_dir_in_zone(&coord_room, &room.zone, &Direction::SOUTH) {
match coord_room.and_then( buf.push_str(" | ");
|r| r.exits.iter().find(|ex| ex.direction == Direction::SOUTH)) { } else {
None => buf.push_str(" "), buf.push_str(" ");
Some(_) => buf.push_str(" | ")
} }
let has_se = coord_room.and_then( let has_se = has_exit_to_dir_in_zone(&coord_room, &room.zone, &Direction::SOUTHEAST);
|r| r.exits.iter().find(|ex| ex.direction == Direction::SOUTHEAST)) coord.x += 1;
.is_some(); let coord_room_s = room::room_map_by_zloc().get(&(&room.zone, &coord));
coord.y += 1; let has_sw = has_exit_to_dir_in_zone(&coord_room_s, &room.zone, &Direction::SOUTHWEST);
let coord_room_s = room::room_map_by_zloc() if has_se && has_sw {
.get(&(&room.zone, &coord));
let has_ne = coord_room_s.and_then(
|r| r.exits.iter().find(|ex| ex.direction == Direction::NORTHEAST))
.is_some();
if has_se && has_ne {
buf.push('X'); buf.push('X');
} else if has_se { } else if has_se {
buf.push('\\'); buf.push('\\');
} else if has_ne { } else if has_sw {
buf.push('/'); buf.push('/');
} else { } else {
buf.push(' '); buf.push(' ');
@ -91,44 +223,346 @@ pub fn render_lmap(room: &room::Room, width: usize, height: usize,
buf buf
} }
pub fn caption_lmap(captions: &Vec<(usize, &'static str, &'static str)>, width: usize, height: usize) -> String { pub fn render_lmap_dynroom<'l, 'm>(
zone: &'l dynzone::Dynzone,
room: &'l dynzone::Dynroom,
width: usize,
height: usize,
captions_needed: &'m mut Vec<(usize, &'l str, &'l str)>,
connectwhere: Option<&'l str>,
) -> String {
let mut buf = String::new();
let my_loc = &room.grid_coords;
let min_x = my_loc.x - (width as i64) / 2;
let max_x = min_x + (width as i64);
let min_y = my_loc.y - (height as i64) / 2;
let max_y = min_y + (height as i64);
let main_exit_dat: Option<(GridCoords, Direction)> = zone
.dyn_rooms
.iter()
.flat_map(|(_, dr)| {
dr.exits
.iter()
.filter(|ex| match ex.target {
dynzone::ExitTarget::ExitZone => true,
_ => false,
})
.map(|ex| (dr.grid_coords.apply(&ex.direction), ex.direction.clone()))
})
.next();
let main_exit = main_exit_dat.as_ref();
for y in min_y..max_y {
for x in min_x..max_x {
let coord = room::GridCoords { x, y, z: my_loc.z };
let coord_room: Option<&dynzone::Dynroom> = zone
.dyn_rooms
.iter()
.find(|(_, dr)| {
dr.grid_coords.x == x && dr.grid_coords.y == y && dr.grid_coords.z == my_loc.z
})
.map(|(_, r)| r);
if my_loc.x == x && my_loc.y == y {
buf.push_str(ansi!("<bgblue><red> () <reset>"));
if let Some(room) = coord_room {
if room.should_caption {
captions_needed.push((
(((my_loc.x as i64 - room.grid_coords.x).abs()
+ (my_loc.y as i64 - room.grid_coords.y).abs())
as usize),
room.short,
room.name,
));
}
match room.exits.iter().find(|ex| ex.direction == Direction::EAST) {
None => buf.push(' '),
Some(_) => buf.push('-'),
}
}
} else if let Some(room) = coord_room {
if room.should_caption {
captions_needed.push((
(((my_loc.x as i64 - room.grid_coords.x).abs()
+ (my_loc.y as i64 - room.grid_coords.y).abs())
as usize),
room.short,
room.name,
));
}
buf.push('[');
buf.push_str(room.short);
buf.push(']');
match room.exits.iter().find(|ex| ex.direction == Direction::EAST) {
None => buf.push(' '),
Some(_) => buf.push('-'),
}
} else if main_exit.map(|ex| &ex.0) == Some(&coord) {
buf.push_str("[<<]");
match main_exit {
Some((ex_coord, Direction::WEST)) => {
buf.push('-');
if let Some(connect) = connectwhere {
captions_needed.push((
((my_loc.x as i64 - ex_coord.x).abs()
+ (my_loc.y as i64 - ex_coord.y).abs())
as usize,
"<<",
connect,
))
}
}
_ => buf.push(' '),
}
} else {
buf.push_str(" ");
}
}
buf.push('\n');
for x in min_x..max_x {
let mut coord = room::GridCoords { x, y, z: my_loc.z };
let coord_room: Option<&'l dynzone::Dynroom> = zone
.dyn_rooms
.iter()
.find(|(_, dr)| {
dr.grid_coords.x == x && dr.grid_coords.y == y && dr.grid_coords.z == my_loc.z
})
.map(|(_, r)| r);
match coord_room
.and_then(|r| r.exits.iter().find(|ex| ex.direction == Direction::SOUTH))
{
Some(_) => buf.push_str(" | "),
None if main_exit == Some(&(coord.clone(), Direction::NORTH)) => {
buf.push_str(" | ")
}
None => buf.push_str(" "),
}
let has_se = coord_room
.and_then(|r| {
r.exits
.iter()
.find(|ex| ex.direction == Direction::SOUTHEAST)
})
.is_some()
|| (main_exit == Some(&(coord.clone(), Direction::NORTHWEST)));
coord.x += 1;
let coord_room_s = zone
.dyn_rooms
.iter()
.find(|(_, dr)| {
dr.grid_coords.x == coord.x
&& dr.grid_coords.y == coord.y
&& dr.grid_coords.z == my_loc.z
})
.map(|(_, r)| r);
let has_sw = coord_room_s
.and_then(|r| {
r.exits
.iter()
.find(|ex| ex.direction == Direction::SOUTHWEST)
})
.is_some()
|| (main_exit == Some(&(coord, Direction::NORTHEAST)));
if has_se && has_sw {
buf.push('X');
} else if has_se {
buf.push('\\');
} else if has_sw {
buf.push('/');
} else {
buf.push(' ');
}
}
buf.push('\n');
}
captions_needed.sort_unstable_by(|a, b| a.0.cmp(&b.0));
buf
}
pub fn caption_lmap<'l>(
captions: &Vec<(usize, &'l str, &'l str)>,
width: usize,
height: usize,
) -> String {
let mut buf = String::new(); let mut buf = String::new();
for room in captions.iter().take(height) { for room in captions.iter().take(height) {
buf.push_str(&format!(ansi!("{}<bold>: {:.*}<reset>\n"), room.1, width, room.2)); buf.push_str(&format!(
ansi!("{}<bold>: {:.*}<reset>\n"),
room.1, width, room.2
));
} }
buf buf
} }
pub async fn lmap_room(ctx: &VerbContext<'_>, #[async_trait]
room: &room::Room) -> UResult<()> { trait MapType {
let mut captions: Vec<(usize, &'static str, &'static str)> = Vec::new(); async fn map_room(&self, ctx: &VerbContext<'_>, room: &room::Room) -> UResult<()>;
ctx.trans.queue_for_session( async fn map_room_dyn<'a>(
ctx.session, &self,
Some(&flow_around(&render_lmap(room, 9, 7, &mut captions), 45, ansi!("<reset> "), ctx: &VerbContext<'_>,
&caption_lmap(&captions, 14, 27), 31 zone: &'a dynzone::Dynzone,
)) room: &'a dynzone::Dynroom,
).await?; zoneref: &str,
Ok(()) ) -> UResult<()>;
}
pub struct LmapType;
#[async_trait]
impl MapType for LmapType {
async fn map_room(&self, ctx: &VerbContext<'_>, room: &room::Room) -> UResult<()> {
let mut captions: Vec<(usize, &'static str, &'static str)> = Vec::new();
ctx.trans
.queue_for_session(
ctx.session,
Some(&flow_around(
&render_lmap(room, 9, 7, &mut captions),
45,
ansi!("<reset> "),
&caption_lmap(&captions, 14, 27),
31,
)),
)
.await?;
Ok(())
}
async fn map_room_dyn<'a>(
&self,
ctx: &VerbContext<'_>,
zone: &'a dynzone::Dynzone,
room: &'a dynzone::Dynroom,
zoneref: &str,
) -> UResult<()> {
let mut captions: Vec<(usize, &str, &str)> = Vec::new();
let connectwhere_name_opt: Option<String> = match zoneref.split_once("/") {
None => None,
Some((zone_t, zone_c)) => {
let zone_item: Option<Arc<Item>> =
ctx.trans.find_item_by_type_code(zone_t, zone_c).await?;
match zone_item.as_ref().map(|v| v.as_ref()) {
Some(Item {
special_data:
Some(ItemSpecialData::DynzoneData {
zone_exit: Some(zone_exit),
..
}),
..
}) => match zone_exit.split_once("/") {
None => None,
Some((ex_t, ex_c)) => {
match ctx.trans.find_item_by_type_code(ex_t, ex_c).await?.as_ref() {
Some(dest_item) => Some(dest_item.display_for_sentence(1, true)),
None => None,
}
}
},
_ => None,
}
}
};
let lmap_str = render_lmap_dynroom(
zone,
room,
9,
7,
&mut captions,
connectwhere_name_opt.as_ref().map(|v| v.as_str()),
);
ctx.trans
.queue_for_session(
ctx.session,
Some(&flow_around(
&lmap_str,
45,
ansi!("<reset> "),
&caption_lmap(&captions, 14, 27),
31,
)),
)
.await?;
Ok(())
}
}
pub struct GmapType;
#[async_trait]
impl MapType for GmapType {
async fn map_room(&self, ctx: &VerbContext<'_>, room: &room::Room) -> UResult<()> {
ctx.trans
.queue_for_session(ctx.session, Some(&render_map(room, 32, 18)))
.await?;
Ok(())
}
async fn map_room_dyn<'a>(
&self,
ctx: &VerbContext<'_>,
zone: &'a dynzone::Dynzone,
room: &'a dynzone::Dynroom,
_zoneref: &str,
) -> UResult<()> {
ctx.trans
.queue_for_session(ctx.session, Some(&render_map_dyn(zone, room, 16, 9)))
.await?;
Ok(())
}
} }
pub struct Verb; pub struct Verb;
#[async_trait] #[async_trait]
impl UserVerb for Verb { impl UserVerb for Verb {
async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { async fn handle(
self: &Self,
ctx: &mut VerbContext,
verb: &str,
remaining: &str,
) -> UResult<()> {
if remaining.trim() != "" { if remaining.trim() != "" {
user_error("map commands don't take anything after them".to_owned())?; user_error("map commands don't take anything after them".to_owned())?;
} }
let map_type: Box<dyn MapType + Sync + Send> = match verb {
"lmap" | "lm" => Box::new(LmapType),
"gmap" | "gm" => Box::new(GmapType),
_ => user_error("I don't know how to show that map type.".to_owned())?,
};
let player_item = get_player_item_or_fail(ctx).await?; let player_item = get_player_item_or_fail(ctx).await?;
let (heretype, herecode) = player_item.location.split_once("/").unwrap_or(("room", "repro_xv_chargen")); let (heretype, herecode) = player_item
let room_item: Arc<Item> = ctx.trans.find_item_by_type_code(heretype, herecode).await? .location
.split_once("/")
.unwrap_or(("room", "repro_xv_chargen"));
let room_item: Arc<Item> = ctx
.trans
.find_item_by_type_code(heretype, herecode)
.await?
.ok_or_else(|| UserError("Sorry, that no longer exists".to_owned()))?; .ok_or_else(|| UserError("Sorry, that no longer exists".to_owned()))?;
if room_item.item_type != "room" { if room_item.item_type == "room" {
user_error("Can't map here".to_owned())?; let room = room::room_map_by_code()
} else { .get(room_item.item_code.as_str())
let room =
room::room_map_by_code().get(room_item.item_code.as_str())
.ok_or_else(|| UserError("Sorry, that room no longer exists".to_owned()))?; .ok_or_else(|| UserError("Sorry, that room no longer exists".to_owned()))?;
lmap_room(ctx, &room).await?; if !is_room_illuminated(ctx, &player_item, &room_item, room).await? {
user_error("It's too dark here to make out the map.".to_owned())?;
}
map_type.map_room(ctx, &room).await?;
} else if room_item.item_type == "dynroom" {
let (dynzone, dynroom) = match &room_item.special_data {
Some(ItemSpecialData::DynroomData {
dynzone_code,
dynroom_code,
}) => dynzone::DynzoneType::from_str(dynzone_code.as_str())
.and_then(|dz_t| dynzone::dynzone_by_type().get(&dz_t))
.and_then(|dz| dz.dyn_rooms.get(dynroom_code.as_str()).map(|dr| (dz, dr)))
.ok_or_else(|| UserError("Dynamic room doesn't exist anymore.".to_owned()))?,
_ => user_error("Expected dynroom to have DynroomData".to_owned())?,
};
map_type
.map_room_dyn(ctx, &dynzone, &dynroom, &room_item.location)
.await?;
} else {
user_error("Can't map here".to_owned())?;
} }
Ok(()) Ok(())
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,348 @@
use super::{
get_player_item_or_fail, look::direction_to_item, user_error, UResult, UserError, UserVerb,
UserVerbRef, VerbContext,
};
#[double]
use crate::db::DBTrans;
use crate::{
models::{
item::{DoorState, Item, LocationActionType},
task::{Task, TaskDetails, TaskMeta},
},
regular_tasks::{
queued_command::{
queue_command_and_save, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
TaskHandler, TaskRunContext,
},
services::comms::broadcast_to_room,
static_content::room::Direction,
DResult,
};
use async_trait::async_trait;
use chrono::{self, Utc};
use mockall_double::double;
use std::sync::Arc;
use std::time;
#[derive(Clone)]
pub struct SwingShutHandler;
pub static SWING_SHUT_HANDLER: &'static (dyn TaskHandler + Sync + Send) = &SwingShutHandler;
#[async_trait]
impl TaskHandler for SwingShutHandler {
async fn do_task(&self, ctx: &mut TaskRunContext) -> DResult<Option<time::Duration>> {
let (room_str, direction) = match &ctx.task.details {
TaskDetails::SwingShut {
room_item,
direction,
} => (room_item, direction),
_ => {
return Ok(None);
}
};
let (room_item_type, room_item_code) = match room_str.split_once("/") {
None => {
return Ok(None);
}
Some(v) => v,
};
let room_item = match ctx
.trans
.find_item_by_type_code(room_item_type, room_item_code)
.await?
{
None => {
return Ok(None);
}
Some(v) => v,
};
let mut room_item_mut = (*room_item).clone();
let door_state = match room_item_mut
.door_states
.as_mut()
.and_then(|ds| ds.get_mut(&direction))
{
Some(v) if v.open => v,
_ => return Ok(None),
};
(*door_state).open = false;
ctx.trans.save_item_model(&room_item_mut).await?;
let msg = format!(
"The door to the {} swings shut with a click.\n",
&direction.describe()
);
broadcast_to_room(&ctx.trans, &room_str, None, &msg).await?;
if let Ok(Some(other_room)) = direction_to_item(&ctx.trans, &room_str, &direction).await {
let msg = format!(
"The door to the {} swings shut with a click.\n",
&direction
.reverse()
.map(|d| d.describe())
.unwrap_or_else(|| "outside".to_owned())
);
broadcast_to_room(&ctx.trans, &other_room.refstr(), None, &msg).await?;
}
Ok(None)
}
}
pub async fn attempt_open_immediate(
ctx: &mut QueuedCommandContext<'_>,
direction: &Direction,
) -> UResult<()> {
let use_location = if ctx.item.death_data.is_some() {
user_error("Your ethereal hands don't seem to be able to move the door.".to_owned())?
} else {
&ctx.item.location
};
let (room_1, dir_in_room, room_2) =
match is_door_in_direction(ctx.trans, &direction, use_location).await? {
DoorSituation::NoDoor => user_error("There is no door to open.".to_owned())?,
DoorSituation::DoorIntoRoom {
state: DoorState { open: true, .. },
..
}
| DoorSituation::DoorOutOfRoom {
state: DoorState { open: true, .. },
..
} => user_error("The door is already open.".to_owned())?,
DoorSituation::DoorIntoRoom {
room_with_door,
current_room,
..
} => {
let entering_room_loc = room_with_door.refstr();
if let Some(revdir) = direction.reverse() {
if let Some(lock) = ctx
.trans
.find_by_action_and_location(
&entering_room_loc,
&LocationActionType::InstalledOnDoorAsLock(revdir.clone()),
)
.await?
.first()
{
if let Some(lockcheck) =
lock.static_data().and_then(|pd| pd.lockcheck_handler)
{
lockcheck.cmd(ctx, &lock).await?
}
}
let mut entering_room_mut = (*room_with_door).clone();
if let Some(door_map) = entering_room_mut.door_states.as_mut() {
if let Some(door) = door_map.get_mut(&revdir) {
(*door).open = true;
}
}
ctx.trans.save_item_model(&entering_room_mut).await?;
(room_with_door, revdir, current_room)
} else {
user_error("There's no door possible there.".to_owned())?
}
}
DoorSituation::DoorOutOfRoom {
room_with_door,
new_room,
..
} => {
let mut entering_room_mut = (*room_with_door).clone();
if let Some(door_map) = entering_room_mut.door_states.as_mut() {
if let Some(door) = door_map.get_mut(&direction) {
(*door).open = true;
}
}
ctx.trans.save_item_model(&entering_room_mut).await?;
(room_with_door, direction.clone(), new_room)
}
};
for (loc, dir) in [
(&room_1.refstr(), &dir_in_room.describe()),
(
&room_2.refstr(),
&dir_in_room
.reverse()
.map(|d| d.describe())
.unwrap_or_else(|| "outside".to_owned()),
),
] {
broadcast_to_room(
&ctx.trans,
loc,
None,
&format!(
"{} opens the door to the {}.\n",
&ctx.item.display_for_sentence(1, true),
dir
),
)
.await?;
}
ctx.trans
.upsert_task(&Task {
meta: TaskMeta {
task_code: format!("{}/{}", &room_1.refstr(), &direction.describe()),
next_scheduled: Utc::now() + chrono::TimeDelta::try_seconds(120).unwrap(),
..Default::default()
},
details: TaskDetails::SwingShut {
room_item: room_1.refstr(),
direction: dir_in_room.clone(),
},
})
.await?;
Ok(())
}
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
let direction = match ctx.command {
QueueCommand::OpenDoor { direction } => direction,
_ => user_error("Unexpected command".to_owned())?,
};
let use_location = if ctx.item.death_data.is_some() {
user_error("Your ethereal hands don't seem to be able to move the door.".to_owned())?
} else {
&ctx.item.location
};
match is_door_in_direction(&ctx.trans, &direction, use_location).await? {
DoorSituation::NoDoor => user_error("There is no door to open.".to_owned())?,
DoorSituation::DoorIntoRoom {
state: DoorState { open: true, .. },
..
}
| DoorSituation::DoorOutOfRoom {
state: DoorState { open: true, .. },
..
} => user_error("The door is already open.".to_owned())?,
DoorSituation::DoorIntoRoom {
room_with_door: entering_room,
..
} => {
let entering_room_loc = entering_room.refstr();
if let Some(revdir) = direction.reverse() {
if let Some(lock) = ctx
.trans
.find_by_action_and_location(
&entering_room_loc,
&LocationActionType::InstalledOnDoorAsLock(revdir),
)
.await?
.first()
{
if let Some(lockcheck) =
lock.static_data().and_then(|pd| pd.lockcheck_handler)
{
lockcheck.cmd(ctx, &lock).await?
}
}
}
}
_ => {}
}
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
let direction = match ctx.command {
QueueCommand::OpenDoor { direction } => direction,
_ => user_error("Unexpected command".to_owned())?,
};
attempt_open_immediate(ctx, &direction).await?;
Ok(())
}
}
pub enum DoorSituation {
NoDoor,
DoorIntoRoom {
state: DoorState,
room_with_door: Arc<Item>,
current_room: Arc<Item>,
}, // Can be locked etc...
DoorOutOfRoom {
state: DoorState,
room_with_door: Arc<Item>,
new_room: Arc<Item>,
}, // No lockable.
}
pub async fn is_door_in_direction(
trans: &DBTrans,
direction: &Direction,
use_location: &str,
) -> UResult<DoorSituation> {
let (loc_type_t, loc_type_c) = use_location
.split_once("/")
.ok_or_else(|| UserError("Invalid location".to_owned()))?;
let cur_loc_item = trans
.find_item_by_type_code(loc_type_t, loc_type_c)
.await?
.ok_or_else(|| UserError("Can't find your current location anymore.".to_owned()))?;
let new_loc_item = direction_to_item(trans, use_location, direction)
.await?
.ok_or_else(|| UserError("That exit doesn't really seem to go anywhere!".to_owned()))?;
if let Some(door_state) = cur_loc_item
.door_states
.as_ref()
.and_then(|v| v.get(direction))
{
return Ok(DoorSituation::DoorOutOfRoom {
state: door_state.clone(),
room_with_door: cur_loc_item,
new_room: new_loc_item,
});
}
if let Some(door_state) = new_loc_item.door_states.as_ref().and_then(|v| {
direction
.reverse()
.as_ref()
.and_then(|rev| v.get(rev).map(|door| door.clone()))
}) {
return Ok(DoorSituation::DoorIntoRoom {
state: door_state.clone(),
room_with_door: new_loc_item,
current_room: cur_loc_item,
});
}
Ok(DoorSituation::NoDoor)
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let dir =
Direction::parse(remaining).ok_or_else(|| UserError("Unknown direction".to_owned()))?;
let player_item = get_player_item_or_fail(ctx).await?;
queue_command_and_save(
ctx,
&player_item,
&QueueCommand::OpenDoor {
direction: dir.clone(),
},
)
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,108 @@
use super::{
get_player_item_or_fail, is_likely_illegal, parsing::parse_to_space, search_item_for_user,
user_error, ItemSearchParams, UResult, UserVerb, UserVerbRef, VerbContext,
};
use ansi::{ansi, ignore_special_characters};
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
verb: &str,
remaining: &str,
) -> UResult<()> {
let (to_whom_name, say_what_raw) = if verb.starts_with("r") {
let last_page_from = match ctx
.user_dat
.as_ref()
.and_then(|u| u.last_page_from.as_ref())
{
None => user_error("No one has paged you, so you can't reply.".to_owned())?,
Some(m) => (*m).clone(),
};
(last_page_from, remaining)
} else {
let (to_whom, say_what) = parse_to_space(remaining);
(to_whom.to_owned(), say_what)
};
let say_what = ignore_special_characters(&say_what_raw);
if say_what == "" {
user_error("You need to provide a message to send.".to_owned())?;
}
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error("Shush, the dead can't talk!".to_string())?;
}
let to_whom = search_item_for_user(
ctx,
&ItemSearchParams {
include_active_players: true,
limit: 1,
..ItemSearchParams::base(&player_item, &to_whom_name)
},
)
.await?;
match to_whom.item_type.as_str() {
"player" => {}
_ => user_error("Only players accept pages".to_string())?,
}
if is_likely_illegal(&say_what) {
user_error("Your message was rejected by the content filter".to_string())?;
}
ctx.trans
.queue_for_session(
ctx.session,
Some(&format!(
ansi!("<blue>You page {} on your wristpad: \"{}\"<reset>\n"),
to_whom.display_for_sentence(1, false),
say_what
)),
)
.await?;
if player_item == to_whom {
return Ok(());
}
match to_whom.item_type.as_str() {
"player" => {
match ctx
.trans
.find_session_for_player(&to_whom.item_code)
.await?
{
None => user_error("That character is asleep.".to_string())?,
Some((other_session, _other_session_dets)) => {
if let Some(mut user) =
ctx.trans.find_by_username(&to_whom.item_code).await?
{
user.last_page_from = Some(player_item.item_code.clone());
ctx.trans.save_user_model(&user).await?;
}
ctx.trans
.queue_for_session(
&other_session,
Some(&format!(
ansi!("<blue>Your wristpad beeps with page from {}: \"{}\"<reset>\n"),
player_item.display_for_sentence(1, false),
say_what
)),
)
.await?;
}
}
}
_ => {}
}
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -1,10 +1,17 @@
use super::{
allow::{AllowCommand, ConsentDetails, ConsentTarget},
pay::{FinancialAccount, PaymentRequest},
};
use crate::models::consent::ConsentType;
use ansi::{ansi, strip_special_characters};
use nom::{ use nom::{
bytes::complete::{take_till, take_till1, take_while},
character::{complete::{space0, space1, alpha1, one_of, char, u8}},
combinator::{recognize, fail, eof},
sequence::terminated,
branch::alt, branch::alt,
bytes::complete::tag,
bytes::complete::{take_till, take_till1, take_while, take_while1},
character::complete::{alpha1, char, one_of, space0, space1, u16, u64, u8},
combinator::{cut, eof, fail, map, opt, peek, recognize},
error::{context, VerboseError, VerboseErrorKind}, error::{context, VerboseError, VerboseErrorKind},
sequence::{pair, preceded, terminated},
IResult, IResult,
}; };
@ -13,7 +20,7 @@ pub fn parse_command_name(input: &str) -> (&str, &str) {
let (input, _) = space0(input)?; let (input, _) = space0(input)?;
let (input, cmd) = alt(( let (input, cmd) = alt((
recognize(one_of("-\"':.")), recognize(one_of("-\"':.")),
take_till1(|c| c == ' ' || c == '\t') take_till1(|c| c == ' ' || c == '\t'),
))(input)?; ))(input)?;
let (input, _) = space0(input)?; let (input, _) = space0(input)?;
Ok((input, cmd)) Ok((input, cmd))
@ -21,7 +28,7 @@ pub fn parse_command_name(input: &str) -> (&str, &str) {
match parse(input) { match parse(input) {
/* This parser only fails on empty / whitespace only strings. */ /* This parser only fails on empty / whitespace only strings. */
Err(_) => ("", ""), Err(_) => ("", ""),
Ok((rest, command)) => (command, rest) Ok((rest, command)) => (command, rest),
} }
} }
@ -31,7 +38,7 @@ pub fn parse_to_space(input: &str) -> (&str, &str) {
} }
match parser(input) { match parser(input) {
Err(_) => ("", ""), /* Impossible? */ Err(_) => ("", ""), /* Impossible? */
Ok((rest, token)) => (token, rest) Ok((rest, token)) => (token, rest),
} }
} }
@ -41,7 +48,17 @@ pub fn parse_offset(input: &str) -> (Option<u8>, &str) {
} }
match parser(input) { match parser(input) {
Err(_) => (None, input), Err(_) => (None, input),
Ok((rest, result)) => (Some(result), rest) Ok((rest, result)) => (Some(result), rest),
}
}
pub fn parse_count(input: &str) -> (Option<u8>, &str) {
fn parser(input: &str) -> IResult<&str, u8> {
terminated(u8, char(' '))(input)
}
match parser(input) {
Err(_) => (None, input),
Ok((rest, result)) => (Some(result), rest),
} }
} }
@ -49,21 +66,256 @@ pub fn parse_username(input: &str) -> Result<(&str, &str), &'static str> {
const CATCHALL_ERROR: &'static str = "Must only contain alphanumeric characters or _"; const CATCHALL_ERROR: &'static str = "Must only contain alphanumeric characters or _";
fn parse_valid(input: &str) -> IResult<&str, (), VerboseError<&str>> { fn parse_valid(input: &str) -> IResult<&str, (), VerboseError<&str>> {
let (input, l1) = context("Must start with a letter", alpha1)(input)?; let (input, l1) = context("Must start with a letter", alpha1)(input)?;
let (input, l2) = context(CATCHALL_ERROR, let (input, l2) = context(
take_while(|c: char| c.is_alphanumeric() || c == '_'))(input)?; CATCHALL_ERROR,
take_while(|c: char| c.is_alphanumeric() || c == '_'),
)(input)?;
if l1.len() + l2.len() > 20 { if l1.len() + l2.len() > 20 {
context("Limit of 20 characters", fail::<&str, &str, VerboseError<&str>>)(input)?; context(
"Limit of 20 characters",
fail::<&str, &str, VerboseError<&str>>,
)(input)?;
} }
Ok((input, ())) Ok((input, ()))
} }
match terminated(recognize(parse_valid), alt((space1, eof)))(input) { match terminated(recognize(parse_valid), alt((space1, eof)))(input) {
Ok((input, username)) => Ok((username, input)), Ok((input, username)) => Ok((username, input)),
Err(nom::Err::Error(e)) | Err(nom::Err::Failure(e)) => Err(nom::Err::Error(e)) | Err(nom::Err::Failure(e)) => Err(e
Err(e.errors.into_iter().find_map(|k| match k.1 { .errors
.into_iter()
.find_map(|k| match k.1 {
VerboseErrorKind::Context(s) => Some(s), VerboseErrorKind::Context(s) => Some(s),
_ => None _ => None,
}).unwrap_or(CATCHALL_ERROR)), })
Err(_) => Err(CATCHALL_ERROR) .unwrap_or(CATCHALL_ERROR)),
Err(_) => Err(CATCHALL_ERROR),
}
}
pub fn parse_on_or_default<'l>(input: &'l str, default_on: &'l str) -> (&'l str, &'l str) {
if let Some((a, b)) = input.split_once(" on ") {
(a, b)
} else {
(input, default_on)
}
}
pub fn parse_duration_mins<'l>(input: &'l str) -> Result<(u64, &'l str), String> {
let (input, number) = match u16::<&'l str, ()>(input) {
Err(_) => Err("Invalid number - duration should start with a number, e.g. 5 minutes")?,
Ok(n) => n,
};
let (tok, input) = match input.trim_start().split_once(" ") {
None => (input, ""),
Some(v) => v,
};
Ok((match tok.to_lowercase().as_str() {
"min" | "mins" | "minute" | "minutes" => number as u64,
"h" | "hr" | "hrs" | "hour" | "hours" => (number as u64) * 60,
"d" | "day" | "days" => (number as u64) * 60 * 24,
"w" | "wk" | "wks" | "week" | "weeks" => (number as u64) * 60 * 24 * 7,
_ => Err("Duration number needs to be followed by a valid unit - minutes, hours, days or weeks")?
}, input))
}
pub fn parse_allow<'l>(input: &'l str) -> Result<AllowCommand, String> {
let usage: &'static str =
ansi!("Usage: allow <lt>action> from <lt>user> <lt>options> | allow <lt>action> against <lt>corp> by <lt>corp> <lt>options>. Try <bold>help allow<reset> for more.");
let (consent_type_s, input) = match input.trim_start().split_once(" ") {
None => Err(usage),
Some(v) => Ok(v),
}?;
let consent_type = match ConsentType::from_str(&consent_type_s.trim().to_lowercase()) {
None => Err("Invalid consent type - options are fight, medicine, gifts, visit and share"),
Some(ct) => Ok(ct),
}?;
let (tok, mut input) = match input.trim_start().split_once(" ") {
None => Err(usage),
Some(v) => Ok(v),
}?;
let tok_trim = tok.trim_start().to_lowercase();
let consent_target = if tok_trim == "against" {
if consent_type != ConsentType::Fight {
Err("corps can only currently consent to fight, no other actions")?
} else {
let (my_corp_raw, new_input) = match input.trim_start().split_once(" ") {
None => Err(usage),
Some(v) => Ok(v),
}?;
let my_corp = my_corp_raw.trim_start();
let (tok, new_input) = match new_input.trim_start().split_once(" ") {
None => Err(usage),
Some(v) => Ok(v),
}?;
if tok.trim_start().to_lowercase() != "by" {
Err(usage)?;
}
let (target_corp_raw, new_input) = match new_input.trim_start().split_once(" ") {
None => (new_input.trim_start(), ""),
Some(v) => v,
};
input = new_input;
ConsentTarget::CorpTarget {
from_corp: my_corp,
to_corp: target_corp_raw.trim_start(),
}
}
} else if tok_trim == "from" {
let (target_user_raw, new_input) = match input.trim_start().split_once(" ") {
None => (input.trim_start(), ""),
Some(v) => v,
};
input = new_input;
ConsentTarget::UserTarget {
to_user: target_user_raw.trim_start(),
}
} else {
Err(usage)?
};
let mut consent_details = ConsentDetails::default_for(&consent_type);
loop {
input = input.trim_start();
if input == "" {
break;
}
let (tok, new_input) = match input.split_once(" ") {
None => (input, ""),
Some(v) => v,
};
match tok.to_lowercase().as_str() {
"for" => {
let (minutes, new_input) = parse_duration_mins(new_input)?;
input = new_input;
consent_details.duration_minutes = Some(minutes);
}
"until" => {
let (tok, new_input) = match new_input.split_once(" ") {
None => (new_input, ""),
Some(v) => v,
};
if tok.trim_start().to_lowercase() != "death" {
Err("Option until needs to be followed with death - until death")?
}
consent_details.until_death = true;
input = new_input;
}
"allow" => {
let (tok, new_input) = match new_input.split_once(" ") {
None => (new_input, ""),
Some(v) => v,
};
match tok.trim_start().to_lowercase().as_str() {
"private" => {
consent_details.allow_private = true;
},
"pick" => {
consent_details.allow_pick = true;
},
"revoke" => {
consent_details.freely_revoke = true;
},
_ => Err("Option allow needs to be followed with private, pick or revoke - allow private | allow pick | allow revoke")?
}
input = new_input;
}
"disallow" => {
let (tok, new_input) = match new_input.split_once(" ") {
None => (new_input, ""),
Some(v) => v,
};
match tok.trim_start().to_lowercase().as_str() {
"private" => {
consent_details.allow_private = false;
},
"pick" => {
consent_details.allow_pick = false;
},
_ => Err("Option disallow needs to be followed with private or pick - disallow private | disallow pick")?
}
input = new_input;
}
"in" => {
let (tok, new_input) = match new_input.split_once(" ") {
None => (new_input, ""),
Some(v) => v,
};
consent_details.only_in.push(tok);
input = new_input;
}
_ => Err(format!(
"I don't understand the option \"{}\"",
strip_special_characters(tok)
))?,
}
}
Ok(AllowCommand {
consent_type: consent_type,
consent_target: consent_target,
consent_details: consent_details,
})
}
pub fn parse_payment_request(
input: &str,
my_account: &FinancialAccount,
) -> Result<PaymentRequest<FinancialAccount>, &'static str> {
let input = input.trim_start();
let account_parser = || {
context(
"Expected account to pay to/from to be username, corpname, or me",
cut(terminated(
alt((
map(tag::<&str, &str, VerboseError<&str>>("treasury"), |_| {
FinancialAccount::Treasury
}),
map(tag("me"), |_| my_account.clone()),
map(
take_while1(|c: char| c.is_alphanumeric() || c == '_'),
|n: &str| FinancialAccount::NamedAccount(n.to_owned()),
),
)),
peek(alt((eof, space1))),
)),
)
};
let res = pair(
context(
"No amount to pay found",
preceded(opt(char('$')), terminated(u64, space1)),
),
pair(
opt(preceded(
tag("from"),
preceded(space1, terminated(account_parser(), space1)),
)),
terminated(
context(
"You need to specify who to pay with \"to\"",
preceded(preceded(tag("to"), space1), account_parser()),
),
eof,
),
),
)(input);
const CATCHALL_ERROR: &'static str = "Usage: \"pay $123 from account to account\". Leave off from to default to from you. Account can be \"me\" or a username or corpname";
match res {
Ok((_, (amount, (from, to)))) => Ok(PaymentRequest {
amount,
from: from.unwrap_or_else(|| my_account.clone()),
to,
}),
Err(nom::Err::Error(e)) | Err(nom::Err::Failure(e)) => Err(e
.errors
.into_iter()
.find_map(|k| match k.1 {
VerboseErrorKind::Context(s) => Some(s),
_ => None,
})
.unwrap_or(CATCHALL_ERROR)),
Err(_) => Err(CATCHALL_ERROR),
} }
} }
@ -73,28 +325,29 @@ mod tests {
#[test] #[test]
fn it_parses_normal_command() { fn it_parses_normal_command() {
assert_eq!(parse_command_name("help"), assert_eq!(parse_command_name("help"), ("help", ""));
("help", ""));
} }
#[test] #[test]
fn it_parses_normal_command_with_arg() { fn it_parses_normal_command_with_arg() {
assert_eq!(parse_command_name("help \t testing stuff"), assert_eq!(
("help", "testing stuff")); parse_command_name("help \t testing stuff"),
("help", "testing stuff")
);
} }
#[test] #[test]
fn it_parses_commands_with_leading_whitespace() { fn it_parses_commands_with_leading_whitespace() {
assert_eq!(parse_command_name(" \t \thelp \t testing stuff"), assert_eq!(
("help", "testing stuff")); parse_command_name(" \t \thelp \t testing stuff"),
("help", "testing stuff")
);
} }
#[test] #[test]
fn it_parses_empty_command_names() { fn it_parses_empty_command_names() {
assert_eq!(parse_command_name(""), assert_eq!(parse_command_name(""), ("", ""));
("", "")); assert_eq!(parse_command_name(" \t "), ("", ""));
assert_eq!(parse_command_name(" \t "),
("", ""));
} }
#[test] #[test]
@ -104,7 +357,10 @@ mod tests {
#[test] #[test]
fn it_parses_usernames_with_further_args() { fn it_parses_usernames_with_further_args() {
assert_eq!(parse_username("Wizard_123 with cat"), Ok(("Wizard_123", "with cat"))); assert_eq!(
parse_username("Wizard_123 with cat"),
Ok(("Wizard_123", "with cat"))
);
} }
#[test] #[test]
@ -134,12 +390,18 @@ mod tests {
#[test] #[test]
fn it_fails_on_usernames_with_bad_characters() { fn it_fails_on_usernames_with_bad_characters() {
assert_eq!(parse_username("Wizard!"), Err("Must only contain alphanumeric characters or _")); assert_eq!(
parse_username("Wizard!"),
Err("Must only contain alphanumeric characters or _")
);
} }
#[test] #[test]
fn it_fails_on_long_usernames() { fn it_fails_on_long_usernames() {
assert_eq!(parse_username("A23456789012345678901"), Err("Limit of 20 characters")); assert_eq!(
parse_username("A23456789012345678901"),
Err("Limit of 20 characters")
);
} }
#[test] #[test]
@ -164,4 +426,125 @@ mod tests {
fn parse_offset_supports_offset() { fn parse_offset_supports_offset() {
assert_eq!(parse_offset("2.hello world"), (Some(2), "hello world")) assert_eq!(parse_offset("2.hello world"), (Some(2), "hello world"))
} }
#[test]
fn parse_consent_works_default_options_user() {
assert_eq!(
super::parse_allow("medicine From Athorina"),
Ok(AllowCommand {
consent_type: ConsentType::Medicine,
consent_target: ConsentTarget::UserTarget {
to_user: "Athorina"
},
consent_details: ConsentDetails::default_for(&ConsentType::Medicine)
})
)
}
#[test]
fn parse_consent_works_default_options_corp() {
assert_eq!(
super::parse_allow("Fight Against megacorp By supercorp"),
Ok(AllowCommand {
consent_type: ConsentType::Fight,
consent_target: ConsentTarget::CorpTarget {
from_corp: "megacorp",
to_corp: "supercorp"
},
consent_details: ConsentDetails::default_for(&ConsentType::Fight)
})
)
}
#[test]
fn parse_consent_handles_options() {
assert_eq!(super::parse_allow("fighT fRom athorina For 2 hOurs unTil deAth allOw priVate Disallow pIck alLow revoKe iN here in pit"),
Ok(AllowCommand {
consent_type: ConsentType::Fight,
consent_target: ConsentTarget::UserTarget { to_user: "athorina" },
consent_details: ConsentDetails {
duration_minutes: Some(120),
until_death: true,
allow_private: true,
allow_pick: false,
freely_revoke: true,
only_in: vec!("here", "pit"),
..ConsentDetails::default_for(&ConsentType::Fight)
}
}))
}
#[test]
fn parse_payment_request_happy_path_works() {
assert_eq!(
super::parse_payment_request(
"$10 to mafia",
&FinancialAccount::NamedAccount("namey".to_owned())
),
Ok(PaymentRequest {
amount: 10,
from: FinancialAccount::NamedAccount("namey".to_owned()),
to: FinancialAccount::NamedAccount("mafia".to_owned())
})
)
}
#[test]
fn parse_payment_request_explicit_from_works() {
assert_eq!(
super::parse_payment_request(
"10 from mafia to treasury",
&FinancialAccount::NamedAccount("namey".to_owned())
),
Ok(PaymentRequest {
amount: 10,
from: FinancialAccount::NamedAccount("mafia".to_owned()),
to: FinancialAccount::Treasury
})
)
}
#[test]
fn parse_payment_wrong_start_err() {
assert_eq!(
super::parse_payment_request(
"nonsense",
&FinancialAccount::NamedAccount("namey".to_owned())
),
Err("No amount to pay found")
)
}
#[test]
fn parse_payment_wrong_from_err() {
assert_eq!(
super::parse_payment_request(
"10 from ! to me",
&FinancialAccount::NamedAccount("namey".to_owned())
),
Err("Expected account to pay to/from to be username, corpname, or me")
)
}
#[test]
fn parse_payment_wrong_to_err() {
assert_eq!(
super::parse_payment_request(
"10 from me for fun",
&FinancialAccount::NamedAccount("namey".to_owned())
),
Err("You need to specify who to pay with \"to\"")
)
}
#[test]
fn parse_payment_trailing_junk_err() {
assert_eq!(
super::parse_payment_request(
"10 from me to me for lolz",
&FinancialAccount::NamedAccount("namey".to_owned())
),
Err("Usage: \"pay $123 from account to account\". Leave off from to default to from you. Account can be \"me\" or a username or corpname")
)
}
} }

View File

@ -0,0 +1,267 @@
use super::{
corp::check_corp_perm, get_user_or_fail, parsing::parse_payment_request, user_error, UResult,
UserVerb, UserVerbRef, VerbContext,
};
#[double]
use crate::db::DBTrans;
use crate::{
models::{
corp::{Corp, CorpCommType, CorpId, CorpPermission},
user::{User, UserFlag},
},
DResult,
};
use ansi::ansi;
use async_trait::async_trait;
use log::info;
use mockall_double::double;
#[derive(Clone, Eq, PartialEq, Debug)]
pub enum FinancialAccount {
NamedAccount(String),
Treasury, // Staff only source of unlimited funds
}
#[derive(Clone, PartialEq, Debug)]
pub enum ResolvedFinancialAccount {
CorpAccount((CorpId, Corp)),
UserAccount(User),
Treasury,
}
#[derive(Clone, PartialEq, Debug)]
pub struct PaymentRequest<AccDetails> {
pub from: AccDetails,
pub to: AccDetails,
pub amount: u64,
}
async fn resolve_account(
trans: &DBTrans,
inp: &FinancialAccount,
) -> UResult<ResolvedFinancialAccount> {
match inp {
FinancialAccount::Treasury => Ok(ResolvedFinancialAccount::Treasury),
FinancialAccount::NamedAccount(ref name) => {
match trans.find_corp_by_name(name.as_str()).await? {
Some(corp) => Ok(ResolvedFinancialAccount::CorpAccount(corp)),
None => match trans.find_by_username(name.as_str()).await? {
Some(user) => Ok(ResolvedFinancialAccount::UserAccount(user)),
None => user_error(format!(
"I couldn't find a user or corp matching \"{}\"",
name.as_str()
)),
},
}
}
}
}
async fn resolve_payment_request(
trans: &DBTrans,
inp: &PaymentRequest<FinancialAccount>,
) -> UResult<PaymentRequest<ResolvedFinancialAccount>> {
Ok(PaymentRequest {
from: resolve_account(trans, &inp.from).await?,
to: resolve_account(trans, &inp.to).await?,
amount: inp.amount.clone(),
})
}
async fn verify_access(
trans: &DBTrans,
user: &User,
inp: &PaymentRequest<ResolvedFinancialAccount>,
) -> UResult<()> {
if inp.from == inp.to {
user_error("That seems a bit like a money-go-round!".to_owned())?;
}
if inp.from == ResolvedFinancialAccount::Treasury
|| inp.to == ResolvedFinancialAccount::Treasury
{
if !user.user_flags.contains(&UserFlag::Staff) {
user_error("I'm sorry Dave, I can't let you do that!".to_owned())?;
}
// Treasury deliberately bypasses all other access checks, but
// even staff have to follow the rules for payments not involving
// treasury.
} else {
match inp.from {
ResolvedFinancialAccount::UserAccount(ref usr) => {
if usr.username != user.username {
user_error("Sadly, it appears the wristpad system won't let you access other people's funds.".to_owned())?;
}
}
ResolvedFinancialAccount::CorpAccount(ref corp) => {
match trans
.match_user_corp_by_name(&corp.1.name, &user.username)
.await?
{
None => user_error(format!("You're not even a member of {}!", &corp.1.name))?,
Some((_, _, mem)) => {
if !check_corp_perm(&CorpPermission::Finance, &mem) {
user_error(format!(
"You lack finance permissions in {}",
&corp.1.name
))?;
}
}
}
}
_ => user_error("Oops, can't verify you have access to pay".to_owned())?,
}
}
Ok(())
}
fn get_name(acc: &ResolvedFinancialAccount) -> String {
match acc {
ResolvedFinancialAccount::Treasury => "Treasury".to_owned(),
ResolvedFinancialAccount::UserAccount(ref usr) => usr.username.clone(),
ResolvedFinancialAccount::CorpAccount(ref corp) => corp.1.name.clone(),
}
}
fn get_balance(acc: &ResolvedFinancialAccount) -> u64 {
match acc {
ResolvedFinancialAccount::Treasury => 10000000000,
ResolvedFinancialAccount::UserAccount(ref usr) => usr.credits,
ResolvedFinancialAccount::CorpAccount(ref corp) => corp.1.credits,
}
}
async fn modify_balance<F>(trans: &DBTrans, acc: &mut ResolvedFinancialAccount, f: F) -> DResult<()>
where
F: Fn(u64) -> u64,
{
match acc {
ResolvedFinancialAccount::Treasury => {}
ResolvedFinancialAccount::UserAccount(ref mut usr) => {
usr.credits = f(usr.credits);
trans.save_user_model(&usr).await?;
}
ResolvedFinancialAccount::CorpAccount(ref mut corp) => {
corp.1.credits = f(corp.1.credits);
trans.update_corp_details(&corp.0, &corp.1).await?;
}
}
Ok(())
}
fn involves_treasury(req: &PaymentRequest<ResolvedFinancialAccount>) -> bool {
req.from == ResolvedFinancialAccount::Treasury || req.to == ResolvedFinancialAccount::Treasury
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let remaining = remaining.trim();
let user = get_user_or_fail(ctx)?;
let req = match parse_payment_request(
remaining,
&FinancialAccount::NamedAccount(user.username.clone()),
) {
Err(msg) => user_error(msg.to_owned())?,
Ok(r) => r,
};
if req.amount == 0 || req.amount >= 10000000000000 {
user_error(
"The wristpad seems to require a number between $1 and $10 trillion.".to_owned(),
)?;
}
let mut req = resolve_payment_request(&ctx.trans, &req).await?;
verify_access(&ctx.trans, &user, &req).await?;
if get_balance(&req.from) < req.amount {
user_error(ansi!("The wristpad plays a sad sounding beep, and a red cross comes up, with the text: <red>DECLINED Insufficient Funds<reset>. [Hint: Try having rich parents, or not spending all your credits on smashed avo, and you might be able to afford to make your payments]").to_owned())?;
}
modify_balance(&ctx.trans, &mut req.to, |a| a + req.amount).await?;
modify_balance(&ctx.trans, &mut req.from, |a| a - req.amount).await?;
let mut notify_corps: Vec<(CorpId, String)> = vec![];
match req.from {
ResolvedFinancialAccount::CorpAccount(ref c) => {
notify_corps.push((c.0.clone(), c.1.name.clone()))
}
_ => {}
}
match req.to {
ResolvedFinancialAccount::CorpAccount(ref c) => {
notify_corps.push((c.0.clone(), c.1.name.clone()))
}
_ => {}
}
for (corp_id, corp_name) in notify_corps {
ctx.trans
.broadcast_to_corp(
&corp_id,
&CorpCommType::Notice,
None,
&format!(
ansi!("<green>[{}] {} has transferred ${} from {} to {}.<reset>\n"),
&corp_name,
if involves_treasury(&req) {
"A staff member"
} else {
&user.username
},
req.amount,
&get_name(&req.from),
&get_name(&req.to),
),
)
.await?;
}
match req.to {
ResolvedFinancialAccount::UserAccount(ref other_usr)
if other_usr.username != user.username =>
{
if let Some((sess, _)) = ctx
.trans
.find_session_for_player(&other_usr.username.to_lowercase())
.await?
{
ctx.trans
.queue_for_session(
&sess,
Some(&format!(
ansi!("<green>{} has transferred ${} from {} to you.<reset>\n"),
if involves_treasury(&req) {
"A staff member"
} else {
&user.username
},
req.amount,
&get_name(&req.from),
)),
)
.await?;
}
}
_ => {}
}
if involves_treasury(&req) {
info!(
"{} has transferred ${} from {} to {}.",
&user.username,
req.amount,
&get_name(&req.from),
&get_name(&req.to),
);
}
ctx.trans.queue_for_session(&ctx.session,
Some(ansi!("Your wristpad displays a <green>green<reset> tick, indicating a successful transaction.\n"))).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,143 @@
use crate::{
db::ItemSearchParams,
models::{
item::ItemSpecialData,
task::{Task, TaskDetails, TaskMeta},
},
services::comms::broadcast_to_room,
static_content::{
dynzone::{dynzone_by_type, DynzoneType},
possession_type::possession_data,
room::room_map_by_code,
},
DResult,
};
use super::{
get_player_item_or_fail, search_item_for_user, user_error, CommandHandlingError, UResult,
UserError, UserVerb, UserVerbRef, VerbContext,
};
#[double]
use crate::db::DBTrans;
use ansi::ansi;
use async_trait::async_trait;
use chrono::{Duration, Utc};
use mockall_double::double;
async fn place_has_power(trans: &DBTrans, place_type: &str, place_code: &str) -> DResult<bool> {
match place_type {
"room" => room_map_by_code()
.get(place_code)
.map(|r| Ok(r.has_power))
.unwrap_or(Ok(false)),
"dynroom" => trans
.find_item_by_type_code(place_type, place_code)
.await?
.and_then(|place| match place.special_data.as_ref() {
Some(ItemSpecialData::DynroomData {
dynzone_code,
dynroom_code,
}) => DynzoneType::from_str(&dynzone_code)
.and_then(|dzt| dynzone_by_type().get(&dzt))
.and_then(|dz| dz.dyn_rooms.get(dynroom_code.as_str()))
.map(|dr| Ok(dr.has_power)),
_ => None,
})
.unwrap_or(Ok(false)),
_ => Ok(false),
}
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let (word, remaining) = match remaining.split_once(" ") {
None => user_error(ansi!("Try <bold>plug in<reset> something").to_owned())?,
Some(v) => v,
};
if word.trim() != "in" {
user_error(ansi!("Try <bold>plug in<reset> something").to_owned())?;
}
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error("Plugging metal things in can make you dead, but being dead doesn't let you plug things in.".to_owned())?;
}
let remaining = remaining.trim();
let item = search_item_for_user(
ctx,
&ItemSearchParams {
limit: 1,
include_loc_contents: true,
item_type_only: Some("possession"),
..ItemSearchParams::base(&player_item, &remaining)
},
)
.await
.map_err(|e| match e {
CommandHandlingError::UserError(m) => CommandHandlingError::UserError(
m + " I only looked in the room - try dropping an item first.",
),
e => e,
})?;
let charge_data = match item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
.and_then(|pd| pd.charge_data.as_ref())
{
Some(v) if v.electric_recharge => v,
_ => user_error("You can't recharge that!".to_owned())?,
};
let (place_type, place_code) = player_item
.location
.split_once("/")
.ok_or_else(|| UserError("Bad location".to_owned()))?;
if !place_has_power(&ctx.trans, place_type, place_code).await? {
user_error("You can't find any power sockets to plug in to.".to_owned())?;
}
if item.charges >= charge_data.max_charges {
user_error("You realise it's already fully charged.".to_owned())?;
}
let refstr = item.refstr();
if ctx
.trans
.check_task_by_type_code("ChargeItem", &refstr)
.await?
{
user_error("It's already charging!".to_owned())?;
}
ctx.trans
.upsert_task(&Task {
meta: TaskMeta {
task_code: refstr.clone(),
next_scheduled: Utc::now() + Duration::try_minutes(1).unwrap(),
..Default::default()
},
details: TaskDetails::ChargeItem { item: refstr },
})
.await?;
broadcast_to_room(
&ctx.trans,
&player_item.location,
None,
&format!(
"{} plugs in {}.\n",
&player_item.display_for_sentence(1, true),
&item.display_for_sentence(1, false)
),
)
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,33 @@
use crate::{models::effect::EffectType, services::combat::switch_to_power_attack};
use super::{get_player_item_or_fail, user_error, UResult, UserVerb, UserVerbRef, VerbContext};
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error("Power attack while dead? You can't even do a regular attack.".to_owned())?;
}
if player_item
.active_effects
.iter()
.any(|v| v.0 == EffectType::Stunned)
{
user_error("You stay still like a stunned mullet, unable to gain the composure to powerattack.".to_owned())?;
}
switch_to_power_attack(ctx, &player_item).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,383 @@
use super::{
get_player_item_or_fail, parsing::parse_count, search_item_for_user, search_items_for_user,
user_error, ItemSearchParams, UResult, UserError, UserVerb, UserVerbRef, VerbContext,
};
use crate::{
models::item::{Item, ItemFlag, LocationActionType},
regular_tasks::queued_command::{
queue_command, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::{
capacity::{check_item_capacity, recalculate_container_weight, CapacityLevel},
comms::broadcast_to_room,
},
static_content::possession_type::{possession_data, ContainerFlag},
};
use ansi::ansi;
use async_trait::async_trait;
use std::time;
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error(
"You try to put it in, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let (container_code, item_code) = match ctx.command {
QueueCommand::Put {
container_possession_id,
target_possession_id,
} => (container_possession_id, target_possession_id),
_ => user_error("Expected Put command".to_owned())?,
};
let container = ctx
.trans
.find_item_by_type_code("possession", &container_code)
.await?
.ok_or_else(|| UserError("Item to put in not found".to_owned()))?;
if container.location != ctx.item.location && container.location != ctx.item.refstr() {
user_error(format!(
"You try to put something in {} but realise {} is no longer there",
container.display_for_sentence(1, false),
container.display_for_sentence(1, false),
))?
}
let item = ctx
.trans
.find_item_by_type_code("possession", &item_code)
.await?
.ok_or_else(|| UserError("Item to place not found".to_owned()))?;
if item.location != ctx.item.refstr() {
user_error(format!(
"You try to put {} in {}, but realise you no longer have it",
item.display_for_sentence(1, false),
container.display_for_sentence(1, false),
))?
}
let msg = format!(
"{} fumbles around trying to put {} in {}.\n",
&ctx.item.display_for_sentence(1, true),
&item.display_for_sentence(1, false),
&container.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error(
"You try to put it in, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let (container_code, item_code) = match ctx.command {
QueueCommand::Put {
container_possession_id,
target_possession_id,
} => (container_possession_id, target_possession_id),
_ => user_error("Expected Put command".to_owned())?,
};
let container = ctx
.trans
.find_item_by_type_code("possession", &container_code)
.await?
.ok_or_else(|| UserError("Item to put in not found".to_owned()))?;
if container.location != ctx.item.location && container.location != ctx.item.refstr() {
user_error(format!(
"You try to put something in {} but realise {} is no longer there",
container.display_for_sentence(1, false),
container.display_for_sentence(1, false),
))?
}
let item = ctx
.trans
.find_item_by_type_code("possession", &item_code)
.await?
.ok_or_else(|| UserError("Item to place not found".to_owned()))?;
if item.location != ctx.item.refstr() {
user_error(format!(
"You try to put {} in {}, but realise you no longer have it",
item.display_for_sentence(1, false),
container.display_for_sentence(1, false),
))?
}
let container_data = container
.possession_type
.as_ref()
.and_then(|pt| {
possession_data()
.get(pt)
.and_then(|pd| pd.container_data.as_ref())
})
.ok_or_else(|| {
UserError(format!(
"You try to put {} in {}, but can't find out a way to get anything in it",
item.display_for_sentence(1, false),
container.display_for_sentence(1, false),
))
})?;
container_data.checker.check_place(&container, &item)?;
let msg = format!(
"{} puts {} in {}.\n",
&ctx.item.display_for_sentence(1, true),
&item.display_for_sentence(1, false),
&container.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
let possession_dat = match item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(&pt))
{
None => {
user_error("That item no longer exists in the game so can't be handled".to_owned())?
}
Some(pd) => pd,
};
let is_loadable = container
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(&pt))
.map_or(false, |pd| pd.weapon_data.is_some());
match check_item_capacity(ctx.trans, &container, possession_dat.weight).await? {
CapacityLevel::AboveItemLimit | CapacityLevel::OverBurdened if is_loadable => {
user_error(format!(
"{} is already fully loaded",
container.display_for_sentence(1, true)
))?
}
CapacityLevel::AboveItemLimit => user_error(format!(
"{} just can't hold that many things!",
container.display_for_sentence(1, true),
))?,
CapacityLevel::OverBurdened => user_error(format!(
"{} You can't place {} because it is too heavy!",
if true { "Fuck!" } else { "Rats!" },
&ctx.item.display_for_sentence(1, false)
))?,
_ => (),
}
let mut item_mut = (*item).clone();
item_mut.location = container.refstr();
item_mut.action_type = LocationActionType::Normal;
ctx.trans.save_item_model(&item_mut).await?;
recalculate_container_weight(&ctx.trans, &container).await?;
Ok(())
}
}
pub enum PutCasesThatSkipContainer {
BookInWorkstation,
AmmoInWeapon,
}
impl PutCasesThatSkipContainer {
async fn announce(
&self,
ctx: &mut VerbContext<'_>,
into_what: &Item,
target: &Item,
) -> UResult<()> {
match self {
Self::BookInWorkstation => {
ctx.trans
.queue_for_session(&ctx.session,
Some(
&format!("For ease of later use, you decide to rip the pages out of {} before placing them in {}.\n",
&target.display_for_sentence(1, false),
&into_what.display_for_sentence(1, false)),
)
).await?;
}
_ => {}
}
Ok(())
}
}
fn check_for_special_put_case(
into_what: &Item,
target: &Item,
) -> Option<PutCasesThatSkipContainer> {
if into_what.flags.contains(&ItemFlag::Bench) && target.flags.contains(&ItemFlag::Book) {
return Some(PutCasesThatSkipContainer::BookInWorkstation);
}
let into_pd = match into_what
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
{
None => return None,
Some(v) => v,
};
if into_pd.container_data.is_none() {
return None;
}
let target_pd = match target
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
{
None => return None,
Some(v) => v,
};
let target_cd = match target_pd.container_data.as_ref() {
None => return None,
Some(v) => v,
};
if into_pd.weapon_data.is_some() && target_cd.container_flags.contains(&ContainerFlag::AmmoClip)
{
return Some(PutCasesThatSkipContainer::AmmoInWeapon);
}
None
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
mut remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
let mut get_limit = Some(1);
if remaining == "all" || remaining.starts_with("all ") {
remaining = remaining[3..].trim();
get_limit = None;
} else if let (Some(n), remaining2) = parse_count(remaining) {
get_limit = Some(n);
remaining = remaining2;
}
let (into_what, for_what) = match remaining
.split_once(" in ")
.or_else(|| remaining.split_once(" into "))
{
None => {
user_error(ansi!("Try <bold>put<reset> item <bold>in<reset> container").to_owned())?
}
Some((item_str_raw, container_str_raw)) => {
let container = search_item_for_user(
ctx,
&ItemSearchParams {
include_loc_contents: true,
include_contents: true,
item_type_only: Some("possession"),
..ItemSearchParams::base(&player_item, container_str_raw.trim())
},
)
.await?;
(container, item_str_raw.trim())
}
};
let targets = search_items_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
item_type_only: Some("possession"),
limit: get_limit.unwrap_or(100),
..ItemSearchParams::base(&player_item, for_what)
},
)
.await?;
if player_item.death_data.is_some() {
user_error(
"You try to put it in, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let mut did_anything: bool = false;
let mut player_item_mut = (*player_item).clone();
for target in targets
.iter()
.filter(|t| t.action_type.is_visible_in_look())
{
if target.item_type == into_what.item_type && target.item_code == into_what.item_code {
user_error(
"You briefly ponder whether something can contain itself, but it blows your mind and you give up.".to_owned()
)?;
}
if target.item_type != "possession" {
user_error("You can't put that in something!".to_owned())?;
}
did_anything = true;
match check_for_special_put_case(&into_what, &target) {
None => {}
Some(special_put) => {
let subitems = ctx.trans.find_items_by_location(&target.refstr()).await?;
if !subitems.is_empty() {
ctx.trans
.queue_for_session(&ctx.session,
Some(
&format!("For ease of later use, you decide to rip the pages out of {} before placing them in {}.\n",
&target.display_for_sentence(1, false),
&into_what.display_for_sentence(1, false)),
)
).await?;
for subitem in subitems.iter().take(10) {
special_put.announce(ctx, &into_what, &target).await?;
queue_command(
ctx,
&mut player_item_mut,
&QueueCommand::GetFromContainer {
from_item_id: target.refstr(),
get_possession_id: subitem.item_code.clone(),
},
)
.await?;
queue_command(
ctx,
&mut player_item_mut,
&QueueCommand::Put {
container_possession_id: into_what.item_code.clone(),
target_possession_id: subitem.item_code.clone(),
},
)
.await?;
}
continue;
}
}
}
queue_command(
ctx,
&mut player_item_mut,
&QueueCommand::Put {
container_possession_id: into_what.item_code.clone(),
target_possession_id: target.item_code.clone(),
},
)
.await?;
}
if !did_anything {
user_error("I didn't find anything matching.".to_owned())?;
} else {
ctx.trans.save_item_model(&player_item_mut).await?;
}
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -1,15 +1,19 @@
use super::{ use super::{UResult, UserVerb, UserVerbRef, VerbContext};
VerbContext, UserVerb, UserVerbRef, UResult
};
use async_trait::async_trait;
use ansi::ansi; use ansi::ansi;
use async_trait::async_trait;
pub struct Verb; pub struct Verb;
#[async_trait] #[async_trait]
impl UserVerb for Verb { impl UserVerb for Verb {
async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, _remaining: &str) -> UResult<()> { async fn handle(
ctx.trans.queue_for_session(ctx.session, self: &Self,
Some(ansi!("<red>Bye!<reset>\r\n"))).await?; ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
ctx.trans
.queue_for_session(ctx.session, Some(ansi!("<red>Bye!<reset>\r\n")))
.await?;
ctx.trans.queue_for_session(ctx.session, None).await?; ctx.trans.queue_for_session(ctx.session, None).await?;
Ok(()) Ok(())
} }

View File

@ -0,0 +1,237 @@
use super::{
get_player_item_or_fail, search_item_for_user, user_error, ItemSearchParams, UResult, UserVerb,
UserVerbRef, VerbContext,
};
use crate::{
models::item::LocationActionType,
regular_tasks::queued_command::{
queue_command_and_save, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::{comms::broadcast_to_room, urges::recalculate_urge_growth},
static_content::room::{room_map_by_code, MaterialType},
};
use async_trait::async_trait;
use std::time;
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error(
"You try to recline, but it turns out the dead can't even do that!".to_owned(),
)?;
}
let item_ref = match ctx.command {
QueueCommand::Recline { item } => item,
_ => user_error("Unexpected command".to_owned())?,
};
if ctx
.item
.active_combat
.as_ref()
.and_then(|ac| ac.attacking.as_ref())
.is_some()
{
user_error(
"Recline... while fighting? You can't figure out how to make it work".to_owned(),
)?
}
if ctx.item.active_climb.is_some() {
user_error(
"Recline... while climbing? You can't figure out how to make it work".to_owned(),
)?
}
match ctx.item.action_type {
LocationActionType::Reclining { .. } => {
user_error("You're already reclining.".to_owned())?
}
_ => {}
}
let (loc_type, loc_code) = match ctx.item.location.split_once("/") {
None => user_error("You've ended up in a bad place.".to_owned())?,
Some(v) => v,
};
if loc_type == "room" {
if let Some(room) = room_map_by_code().get(loc_code) {
match room.material_type {
MaterialType::Underwater | MaterialType::WaterSurface => {
user_error("You're already floating around".to_owned())?
}
_ => {}
}
}
}
match item_ref {
None => {}
Some(item_ref) => {
let (item_type, item_code) = match item_ref.split_once("/") {
None => user_error("Invalid item ref in Reclining command".to_owned())?,
Some(v) => v,
};
match ctx
.trans
.find_item_by_type_code(&item_type, &item_code)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(item) => {
if item.location != ctx.item.location {
user_error(format!(
"You try to reclining on {} but realise it's no longer here",
item.display_for_sentence(1, false)
))?
}
}
}
}
};
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error(
"You try to recline, but it turns out the dead can't even do that!".to_owned(),
)?;
}
let item_ref = match ctx.command {
QueueCommand::Recline { item } => item,
_ => user_error("Unexpected command".to_owned())?,
};
if ctx
.item
.active_combat
.as_ref()
.and_then(|ac| ac.attacking.as_ref())
.is_some()
{
user_error(
"Recline... while fighting? You can't figure out how to make it work".to_owned(),
)?
}
if ctx.item.active_climb.is_some() {
user_error(
"Recline... while climbing? You can't figure out how to make it work".to_owned(),
)?
}
match ctx.item.action_type {
LocationActionType::Reclining { .. } => {
user_error("You're already reclining.".to_owned())?
}
_ => {}
}
let (loc_type, loc_code) = match ctx.item.location.split_once("/") {
None => user_error("You've ended up in a bad place.".to_owned())?,
Some(v) => v,
};
if loc_type == "room" {
if let Some(room) = room_map_by_code().get(loc_code) {
match room.material_type {
MaterialType::Underwater | MaterialType::WaterSurface => {
user_error("You're already floating around".to_owned())?
}
_ => {}
}
}
}
let (item, desc) = match item_ref {
None => (None, "the floor".to_owned()),
Some(item_ref) => {
let (item_type, item_code) = match item_ref.split_once("/") {
None => user_error("Invalid item ref in Sit command".to_owned())?,
Some(v) => v,
};
match ctx
.trans
.find_item_by_type_code(&item_type, &item_code)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(item) => {
if item.location != ctx.item.location {
user_error(format!(
"You try to recline on {} but realise it's no longer here",
item.display_for_sentence(1, false)
))?
}
(Some(item.clone()), item.display_for_sentence(1, false))
}
}
}
};
let msg = format!(
"{} reclines on {}.\n",
&ctx.item.display_for_sentence(1, true),
&desc
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
ctx.item.action_type = LocationActionType::Reclining(item.map(|it| it.refstr()));
recalculate_urge_growth(&ctx.trans, &mut ctx.item).await?;
Ok(())
}
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
mut remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
if remaining.starts_with("on ") {
remaining = remaining[3..].trim();
}
if remaining == "" {
remaining = "floor";
}
let target = if remaining == "here" || remaining == "ground" || remaining == "floor" {
None
} else {
Some(
search_item_for_user(
ctx,
&ItemSearchParams {
include_loc_contents: true,
..ItemSearchParams::base(&player_item, &remaining)
},
)
.await?,
)
};
if player_item.death_data.is_some() {
user_error(
"You try to recline, but it turns out the dead can't even do that!".to_owned(),
)?;
}
if let Some(target) = target.as_ref() {
if target
.static_data()
.and_then(|pd| pd.sit_data.as_ref())
.is_none()
{
user_error("You can't recline on that!".to_owned())?;
}
}
queue_command_and_save(
ctx,
&player_item,
&QueueCommand::Recline {
item: target.map(|t| t.refstr()),
},
)
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -1,44 +1,107 @@
use super::{VerbContext, UserVerb, UserVerbRef, UResult}; use super::{parsing::parse_username, user_error};
use async_trait::async_trait; use super::{UResult, UserVerb, UserVerbRef, VerbContext};
use super::{user_error, parsing::parse_username}; use crate::models::{
use crate::models::{user::User, item::{Item, Pronouns}}; item::{Item, Pronouns},
use chrono::Utc; user::User,
};
use ansi::ansi; use ansi::ansi;
use async_trait::async_trait;
use chrono::Utc;
use once_cell::sync::OnceCell;
use std::collections::HashSet;
use tokio::time; use tokio::time;
pub fn is_invalid_username(name: &str) -> bool {
static INVALID_PREFIXES: OnceCell<Vec<&'static str>> = OnceCell::new();
static INVALID_SUFFIXES: OnceCell<Vec<&'static str>> = OnceCell::new();
static INVALID_WORDS: OnceCell<HashSet<&'static str>> = OnceCell::new();
let invalid_prefixes =
INVALID_PREFIXES.get_or_init(|| vec!["admin", "god", "helper", "npc", "corpse", "dead"]);
let invalid_suffixes = INVALID_SUFFIXES.get_or_init(|| vec!["bot"]);
let invalid_words = INVALID_WORDS.get_or_init(|| {
HashSet::from([
"corp",
"to",
"from",
"dog",
"bot",
"for",
"against",
"on",
"privileges",
"as",
"treasury",
])
});
if invalid_words.contains(name) {
return true;
}
for pfx in invalid_prefixes.iter() {
if name.starts_with(pfx) {
return true;
}
}
for sfx in invalid_suffixes.iter() {
if name.ends_with(sfx) {
return true;
}
}
false
}
pub struct Verb; pub struct Verb;
#[async_trait] #[async_trait]
impl UserVerb for Verb { impl UserVerb for Verb {
async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let (username, password, email) = match parse_username(remaining) { let (username, password, email) = match parse_username(remaining) {
Err(e) => user_error("Invalid username: ".to_owned() + e)?, Err(e) => user_error("Invalid username: ".to_owned() + e)?,
Ok((username, rest)) => { Ok((username, rest)) => match rest.split_whitespace().collect::<Vec<&str>>()[..] {
match rest.split_whitespace().collect::<Vec<&str>>()[..] { [password, email] => (username, password, email),
[password, email] => (username, password, email), [] | [_] => user_error(
[] | [_] => user_error("Too few options to register - supply username, password, and email".to_owned())?, "Too few options to register - supply username, password, and email".to_owned(),
_ => user_error("Too many options to register - supply username, password, and email".to_owned())?, )?,
} _ => user_error(
} "Too many options to register - supply username, password, and email"
.to_owned(),
)?,
},
}; };
if is_invalid_username(&username.to_lowercase()) {
user_error("Sorry, that username isn't allowed. Try another".to_owned())?;
}
if ctx.trans.find_by_username(username).await?.is_some() { if ctx.trans.find_by_username(username).await?.is_some() {
user_error("Username already exists".to_owned())?; user_error("Username already exists".to_owned())?;
} }
if ctx.trans.find_corp_by_name(username).await?.is_some() {
user_error("Username clashes with existing corp name".to_owned())?;
}
if password.len() < 6 { if password.len() < 6 {
user_error("Password must be 6 characters long or longer".to_owned())?; user_error("Password must be 6 characters long or longer".to_owned())?;
} else if !validator::validate_email(email) { } else if !validator::validate_email(email) {
user_error("Please supply a valid email in case you need to reset your password.".to_owned())?; user_error(
"Please supply a valid email in case you need to reset your password.".to_owned(),
)?;
} }
let player_item_id = ctx.trans.create_item(&Item { let player_item_id = ctx
item_type: "player".to_owned(), .trans
item_code: username.to_lowercase(), .create_item(&Item {
display: username.to_owned(), item_type: "player".to_owned(),
details: Some("A non-descript individual".to_owned()), item_code: username.to_lowercase(),
location: "room/repro_xv_chargen".to_owned(), display: username.to_owned(),
pronouns: Pronouns::default_animate(), details: Some("A non-descript individual".to_owned()),
..Item::default() location: "room/repro_xv_chargen".to_owned(),
}).await?; pronouns: Pronouns::default_animate(),
..Item::default()
})
.await?;
// Force a wait to protect against abuse. // Force a wait to protect against abuse.
time::sleep(time::Duration::from_secs(5)).await; time::sleep(time::Duration::from_secs(5)).await;
@ -53,13 +116,19 @@ impl UserVerb for Verb {
}; };
*ctx.user_dat = Some(user_dat); *ctx.user_dat = Some(user_dat);
ctx.trans.queue_for_session( ctx.trans
ctx.session, .queue_for_session(
Some(&format!(ansi!("Welcome <bold>{}<reset>, you are now officially registered.\r\n"), ctx.session,
&username)) Some(&format!(
).await?; ansi!("Welcome <bold>{}<reset>, you are now officially registered.\r\n"),
&username
)),
)
.await?;
super::agree::check_and_notify_accepts(ctx).await?; super::agree::check_and_notify_accepts(ctx).await?;
ctx.trans.create_user(ctx.session, ctx.user_dat.as_ref().unwrap()).await?; ctx.trans
.create_user(ctx.session, ctx.user_dat.as_ref().unwrap())
.await?;
Ok(()) Ok(())
} }

View File

@ -0,0 +1,242 @@
use super::{
get_player_item_or_fail, parsing::parse_count, search_items_for_user, user_error,
ItemSearchParams, UResult, UserError, UserVerb, UserVerbRef, VerbContext,
};
use crate::{
models::item::{BuffCause, Item, LocationActionType},
regular_tasks::queued_command::{
queue_command, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::{comms::broadcast_to_room, skills::calculate_total_stats_skills_for_user},
static_content::possession_type::{possession_data, WearData},
};
use async_trait::async_trait;
use std::time;
async fn check_removeable(ctx: &mut QueuedCommandContext<'_>, item: &Item) -> UResult<()> {
if item.location != ctx.item.refstr() {
user_error(format!(
"You try to remove {} but realise you no longer have it.",
&item.display_for_sentence(1, false)
))?
}
if item.action_type != LocationActionType::Worn {
user_error("You realise you're not wearing it!".to_owned())?;
}
let poss_data = item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(&pt))
.ok_or_else(|| {
UserError(
"That item no longer exists in the game so can't be handled. Ask staff for help."
.to_owned(),
)
})?;
let wear_data = poss_data.wear_data.as_ref().ok_or_else(|| {
UserError(
"You seem to be wearing something that isn't clothes! Ask staff for help.".to_owned(),
)
})?;
let other_clothes = ctx
.trans
.find_by_action_and_location(&ctx.item.refstr(), &LocationActionType::Worn)
.await?;
if let Some(my_worn_since) = item.action_type_started {
for part in &wear_data.covers_parts {
if let Some(other_item) = other_clothes.iter().find(|other_item| {
match other_item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(&pt))
.and_then(|pd| pd.wear_data.as_ref())
{
None => false,
Some(WearData { covers_parts, .. }) => {
covers_parts.contains(&part)
&& other_item
.action_type_started
.map(|other_worn_since| other_worn_since > my_worn_since)
.unwrap_or(false)
}
}
}) {
user_error(format!(
"You can't do that without first removing your {} from your {}.",
&other_item.display_for_sentence(1, false),
part.display(ctx.item.sex.clone())
))?;
}
}
}
Ok(())
}
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error(
"You try to remove it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let item_id = match ctx.command {
QueueCommand::Remove { possession_id } => possession_id,
_ => user_error("Unexpected command".to_owned())?,
};
let item = match ctx
.trans
.find_item_by_type_code("possession", &item_id)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
check_removeable(ctx, &item).await?;
let msg = format!(
"{} fumbles around trying to take off {}\n",
&ctx.item.display_for_sentence(1, true),
&item.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error(
"You try to remove it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let item_id = match ctx.command {
QueueCommand::Remove { possession_id } => possession_id,
_ => user_error("Unexpected command".to_owned())?,
};
let item = match ctx
.trans
.find_item_by_type_code("possession", &item_id)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
check_removeable(ctx, &item).await?;
let msg = format!(
"{} removes {}\n",
&ctx.item.display_for_sentence(1, true),
&item.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
let mut item_mut = (*item).clone();
item_mut.action_type = LocationActionType::Normal;
item_mut.action_type_started = None;
let poss_data = item.possession_type.as_ref()
.and_then(|pt| possession_data().get(&pt))
.ok_or_else(|| UserError(
"That item no longer exists in the game so can't be handled. Ask staff for help.".to_owned()))?;
let wear_data = poss_data.wear_data.as_ref().ok_or_else(|| {
UserError(
"You seem to be wearing something that isn't clothes! Ask staff for help."
.to_owned(),
)
})?;
ctx.trans.save_item_model(&item_mut).await?;
if wear_data.dodge_penalty != 0.0 {
ctx.item.temporary_buffs = ctx
.item
.temporary_buffs
.clone()
.into_iter()
.filter(|buf| {
buf.cause
!= BuffCause::ByItem {
item_code: item_mut.item_code.clone(),
item_type: item_mut.item_type.clone(),
}
})
.collect();
if ctx.item.item_type == "player" {
if let Some(usr) = ctx.trans.find_by_username(&ctx.item.item_code).await? {
calculate_total_stats_skills_for_user(ctx.item, &usr);
}
}
}
Ok(())
}
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
mut remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
let mut get_limit = Some(1);
if remaining == "all" || remaining.starts_with("all ") {
remaining = remaining[3..].trim();
get_limit = None;
} else if let (Some(n), remaining2) = parse_count(remaining) {
get_limit = Some(n);
remaining = remaining2;
}
let targets = search_items_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
item_type_only: Some("possession"),
item_action_type_only: Some(&LocationActionType::Worn),
limit: get_limit.unwrap_or(100),
..ItemSearchParams::base(&player_item, &remaining)
},
)
.await?;
if player_item.death_data.is_some() {
user_error("The dead don't undress themselves".to_owned())?;
}
let mut did_anything: bool = false;
let mut player_item_mut = (*player_item).clone();
for target in targets
.iter()
.filter(|t| t.action_type.is_visible_in_look())
{
if target.item_type != "possession" {
user_error("You can't remove that!".to_owned())?;
}
did_anything = true;
queue_command(
ctx,
&mut player_item_mut,
&QueueCommand::Remove {
possession_id: target.item_code.clone(),
},
)
.await?;
}
if !did_anything {
user_error("I didn't find anything matching.".to_owned())?;
} else {
ctx.trans.save_item_model(&player_item_mut).await?;
}
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,544 @@
use super::{
corp::check_corp_perm, get_player_item_or_fail, get_user_or_fail, user_error, UResult,
UserError, UserVerb, UserVerbRef, VerbContext,
};
#[double]
use crate::db::DBTrans;
use crate::{
models::{
corp::{CorpCommType, CorpPermission},
item::{Item, ItemSpecialData},
task::{Task, TaskDetails, TaskMeta},
},
regular_tasks::{TaskHandler, TaskRunContext},
static_content::{
dynzone::dynzone_by_type,
npc::npc_by_code,
room::{room_map_by_code, Direction, RentSuiteType},
},
DResult,
};
use ansi::ansi;
use async_recursion::async_recursion;
use async_trait::async_trait;
use chrono::{DateTime, TimeDelta, Utc};
use itertools::Itertools;
use log::info;
use mockall_double::double;
use std::time;
#[async_recursion]
pub async fn recursively_destroy_or_move_item(trans: &DBTrans, item: &Item) -> DResult<()> {
let mut item_mut = item.clone();
match item.item_type.as_str() {
"npc" => {
let npc = match npc_by_code().get(item.item_code.as_str()) {
None => return Ok(()),
Some(r) => r,
};
item_mut.location = npc.spawn_location.to_owned();
trans.save_item_model(&item_mut).await?;
return Ok(());
}
"player" => {
let session = trans.find_session_for_player(&item.item_code).await?;
match session.as_ref() {
Some((listener_sess, _)) => {
trans.queue_for_session(
&listener_sess,
Some(ansi!("<red>The landlord barges in with a bunch of very big hired goons, who stuff you in a sack, while the landlord mumbles something about vacant possession.<reset> After what seems like an eternity being jostled along while stuffed in the sack, they dump you out into a room that seems to be some kind of homeless shelter, and beat a hasty retreat.\n"))
).await?;
}
None => {}
}
item_mut.location = "room/melbs_homelessshelter".to_owned();
trans.save_item_model(&item_mut).await?;
return Ok(());
}
_ => {}
}
trans.delete_item(&item.item_type, &item.item_code).await?;
let loc = format!("{}/{}", &item.item_type, &item.item_code);
// It's paginated so we loop to get everything...
loop {
let result = trans.find_items_by_location(&loc).await?;
if result.is_empty() {
return Ok(());
}
for sub_item in result {
recursively_destroy_or_move_item(trans, &sub_item).await?;
}
}
}
static EVICTION_NOTICE: &'static str = ansi!(". Nailed to the door is a notice: <red>Listen here you lazy bum - you didn't pay your rent on time, and so unless you come down in the next 24 hours and re-rent the place (and pay the setup fee again for wasting my time), I'm having you, and any other deadbeats who might be in there with you, evicted, and I'm just gonna sell anything I find in there<reset>");
async fn bill_residential_room(
ctx: &mut TaskRunContext<'_>,
bill_player_code: &str,
daily_price: u64,
zone_item: &Item,
vacate_after: Option<DateTime<Utc>>,
) -> DResult<Option<time::Duration>> {
let mut bill_user = match ctx.trans.find_by_username(bill_player_code).await? {
None => return Ok(None),
Some(user) => user,
};
let session = ctx.trans.find_session_for_player(bill_player_code).await?;
// Check if they have enough money.
if bill_user.credits < daily_price {
if vacate_after.is_some() {
// If they are already on their way out, just ignore it - but we do need
// to keep the task in case they change their mind.
return Ok(Some(time::Duration::from_secs(3600 * 24)));
}
let mut zone_item_mut = (*zone_item).clone();
zone_item_mut.special_data = Some(ItemSpecialData::DynzoneData {
vacate_after: Some(Utc::now() + TimeDelta::try_days(1).unwrap()),
zone_exit: match zone_item.special_data.as_ref() {
Some(ItemSpecialData::DynzoneData { zone_exit, .. }) => zone_exit.clone(),
_ => None,
},
});
ctx.trans.save_item_model(&zone_item_mut).await?;
match ctx
.trans
.find_item_by_type_code("dynroom", &(zone_item.item_code.clone() + "/doorstep"))
.await?
{
None => {}
Some(doorstep_room) => {
let mut doorstep_mut = (*doorstep_room).clone();
doorstep_mut.details =
Some(doorstep_mut.details.clone().unwrap_or("".to_owned()) + EVICTION_NOTICE);
ctx.trans.save_item_model(&doorstep_mut).await?;
}
}
match session.as_ref() {
None => {},
Some((listener, _)) => ctx.trans.queue_for_session(
listener, Some(&format!(
ansi!("<red>Your wristpad beeps a sad sounding tone as your landlord at {} \
tries and fails to take rent.<reset> You'd better earn some more money fast \
and hurry to reception (in the next 24 hours) and sign a new rental \
agreement for the premises, or all your stuff will be gone forever!\n"),
&zone_item.display
)),
).await?
}
return Ok(Some(time::Duration::from_secs(3600 * 24)));
}
bill_user.credits -= daily_price;
ctx.trans.save_user_model(&bill_user).await?;
match session.as_ref() {
None => {},
Some((listener, _)) => ctx.trans.queue_for_session(
listener, Some(&format!(
ansi!("<yellow>Your wristpad beeps for a deduction of ${} for rent for {}.<reset>\n"),
daily_price, &zone_item.display
))
).await?
}
Ok(Some(time::Duration::from_secs(3600 * 24)))
}
async fn bill_commercial_room(
ctx: &mut TaskRunContext<'_>,
bill_corp: &str,
daily_price: u64,
zone_item: &Item,
vacate_after: Option<DateTime<Utc>>,
) -> DResult<Option<time::Duration>> {
let mut bill_corp = match ctx.trans.find_corp_by_name(bill_corp).await? {
None => return Ok(None),
Some(c) => c,
};
// Check if they have enough money.
if bill_corp.1.credits < daily_price {
if vacate_after.is_some() {
// If they are already on their way out, just ignore it - but we do need
// to keep the task in case they change their mind.
return Ok(Some(time::Duration::from_secs(3600 * 24)));
}
let mut zone_item_mut = (*zone_item).clone();
zone_item_mut.special_data = Some(ItemSpecialData::DynzoneData {
vacate_after: Some(Utc::now() + TimeDelta::try_days(1).unwrap()),
zone_exit: match zone_item.special_data.as_ref() {
Some(ItemSpecialData::DynzoneData { zone_exit, .. }) => zone_exit.clone(),
_ => None,
},
});
ctx.trans.save_item_model(&zone_item_mut).await?;
match ctx
.trans
.find_item_by_type_code("dynroom", &(zone_item.item_code.clone() + "/reception"))
.await?
{
None => {}
Some(reception_room) => {
let mut reception_mut = (*reception_room).clone();
reception_mut.details =
Some(reception_mut.details.clone().unwrap_or("".to_owned()) + EVICTION_NOTICE);
ctx.trans.save_item_model(&reception_mut).await?;
}
}
ctx.trans.broadcast_to_corp(
&bill_corp.0,
&CorpCommType::Notice,
None,
&format!(
ansi!("<red>All wristpads of members of {} beep a sad sounding tone as the corp's landlord at {} \
tries and fails to take rent.<reset> You'd better earn some more money for the corp \
fast and have your holder hurry to reception (in the next 24 hours) and sign a new \
lease agreement for the premises, or all your corp's stuff will be gone forever!\n"),
&bill_corp.1.name,
&zone_item.display
),
).await?;
return Ok(Some(time::Duration::from_secs(3600 * 24)));
}
bill_corp.1.credits -= daily_price;
ctx.trans
.update_corp_details(&bill_corp.0, &bill_corp.1)
.await?;
ctx.trans.broadcast_to_corp(
&bill_corp.0,
&CorpCommType::Notice,
None,
&format!(
ansi!("<yellow>Your wristpad beeps as a deduction is made from {}'s account of ${} for rent for {}.<reset>\n"),
&bill_corp.1.name,
daily_price, &zone_item.display
)
).await?;
Ok(Some(time::Duration::from_secs(3600 * 24)))
}
pub struct ChargeRoomTaskHandler;
#[async_trait]
impl TaskHandler for ChargeRoomTaskHandler {
async fn do_task(&self, ctx: &mut TaskRunContext) -> DResult<Option<time::Duration>> {
let (zone_item_ref, daily_price) = match ctx.task.details {
TaskDetails::ChargeRoom {
ref zone_item,
ref daily_price,
} => (zone_item, daily_price),
_ => Err("Expected ChargeRoom type")?,
};
let zone_item_code = match zone_item_ref.split_once("/") {
Some(("dynzone", c)) => c,
_ => Err("Invalid zone item ref when charging room")?,
};
let zone_item = match ctx
.trans
.find_item_by_type_code("dynzone", zone_item_code)
.await?
{
None => {
info!(
"Can't charge rent for dynzone {}, it's gone",
zone_item_code
);
return Ok(None);
}
Some(it) => it,
};
let vacate_after = match zone_item.special_data {
Some(ItemSpecialData::DynzoneData { vacate_after, .. }) => vacate_after,
_ => Err("Expected ChargeRoom dynzone to have DynzoneData")?,
};
match vacate_after {
Some(t) if t < Utc::now() => {
recursively_destroy_or_move_item(&ctx.trans, &zone_item).await?;
return Ok(None);
}
_ => (),
}
match zone_item.owner.as_ref().and_then(|s| s.split_once("/")) {
Some((player_item_type, player_item_code)) if player_item_type == "player" => {
bill_residential_room(
ctx,
player_item_code,
daily_price.clone(),
&zone_item,
vacate_after,
)
.await
}
Some((item_type, corpname)) if item_type == "corp" => {
bill_commercial_room(ctx, corpname, daily_price.clone(), &zone_item, vacate_after)
.await
}
_ => {
info!(
"Can't charge rent for dynzone {}, owner {:?} isn't chargeable",
zone_item_code, &zone_item.owner
);
return Ok(None);
}
}
}
}
pub static CHARGE_ROOM_HANDLER: &'static (dyn TaskHandler + Sync + Send) = &ChargeRoomTaskHandler;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let remaining = remaining.trim();
let (item_name, corp_name) = match remaining.split_once(" for ") {
None => (remaining, None),
Some((i, c)) => (i.trim(), Some(c.trim())),
};
let player_item = get_player_item_or_fail(ctx).await?;
let (loc_type, loc_code) = player_item
.location
.split_once("/")
.ok_or_else(|| UserError("Invalid location".to_owned()))?;
if loc_type != "room" {
user_error("You can't rent anything from here.".to_owned())?;
}
let room = room_map_by_code()
.get(loc_code)
.ok_or_else(|| UserError("Can't find your room".to_owned()))?;
if room.rentable_dynzone.is_empty() {
user_error("You can't rent anything from here.".to_owned())?;
}
let rentinfo = match room
.rentable_dynzone
.iter()
.find(|ri| ri.rent_what == item_name)
{
None => user_error(format!(
"Rent must be followed by the specific thing you want to rent: {}",
room.rentable_dynzone
.iter()
.map(|ri| ri.rent_what.as_str())
.join(", ")
))?,
Some(v) => v,
};
let user = get_user_or_fail(ctx)?;
let mut corp = match (&rentinfo.suite_type, corp_name) {
(RentSuiteType::Commercial, None) =>
user_error(format!(
ansi!("This is a commercial suite, you need to rent it in the name of a corp. Try <bold>rent {} for corpname<reset>"),
item_name
))?,
(RentSuiteType::Residential, Some(_)) =>
user_error("This is a residential suite, you can't rent it for a corp. Try finding a commercial suite for your corp, or rent it personally.".to_owned())?,
(RentSuiteType::Residential, None) => None,
(RentSuiteType::Commercial, Some(n)) => match ctx.trans.match_user_corp_by_name(&n, &user.username).await? {
None => user_error("I can't find that corp in your list of corps!".to_owned())?,
Some((_, _, mem)) if !check_corp_perm(&CorpPermission::Holder, &mem) => user_error("You don't have holder permissions in that corp.".to_owned())?,
Some((corp_id, corp, _)) => Some((corp_id, corp))
},
};
match corp.as_ref() {
None if user.credits < rentinfo.setup_fee =>
user_error("The robot rolls its eyes at you derisively. \"I don't think so - you couldn't even afford the setup fee!\"".to_owned())?,
Some((_, c)) if c.credits < rentinfo.setup_fee =>
user_error("The robot rolls its eyes at you derisively. \"I don't think so - your corp couldn't even afford the setup fee!\"".to_owned())?,
_ => {}
}
let zone = dynzone_by_type().get(&rentinfo.dynzone).ok_or_else(|| {
UserError("That seems to no longer exist, so you can't rent it.".to_owned())
})?;
let exit = Direction::IN {
item: corp
.as_ref()
.map(|c| c.1.name.clone())
.unwrap_or_else(|| player_item.display.clone()),
};
match ctx
.trans
.find_exact_dyn_exit(&player_item.location, &exit)
.await?
.as_ref()
.and_then(|it| it.location.split_once("/"))
{
None => {}
Some((ref ex_zone_t, ref ex_zone_c)) => {
if let Some(ex_zone) = ctx
.trans
.find_item_by_type_code(ex_zone_t, ex_zone_c)
.await?
{
match ex_zone.special_data {
Some(ItemSpecialData::DynzoneData {
vacate_after: None, ..
}) => user_error(
"You can only rent one apartment here, and you already have one!"
.to_owned(),
)?,
Some(ItemSpecialData::DynzoneData {
vacate_after: Some(_),
zone_exit: ref ex,
}) => {
match corp.as_mut() {
None => {
let mut user_mut = user.clone();
user_mut.credits -= rentinfo.setup_fee;
ctx.trans.save_user_model(&user_mut).await?;
ctx.trans
.queue_for_session(
ctx.session,
Some(&format!(
ansi!("<yellow>Your wristpad beeps for a deduction of ${}<reset>\n"),
rentinfo.setup_fee
)),
)
.await?;
}
Some(corptup) => {
corptup.1.credits -= rentinfo.setup_fee;
ctx.trans
.update_corp_details(&corptup.0, &corptup.1)
.await?;
ctx.trans
.broadcast_to_corp(
&corptup.0,
&CorpCommType::Notice,
None,
&format!(
"[{}] {} just cancelled plans to vacate a {} for the corp for a setup fee of ${}.\n",
&corptup.1.name, &user.username, item_name, rentinfo.setup_fee
),
)
.await?;
}
}
ctx.trans
.save_item_model(&Item {
special_data: Some(ItemSpecialData::DynzoneData {
vacate_after: None,
zone_exit: ex.clone(),
}),
..(*ex_zone).clone()
})
.await?;
match ctx
.trans
.find_item_by_type_code(
"dynroom",
&(ex_zone.item_code.clone() + "/" + zone.entrypoint_subcode),
)
.await?
{
None => {}
Some(doorstep_room) => {
let mut doorstep_mut = (*doorstep_room).clone();
doorstep_mut.details = Some(
doorstep_mut
.details
.clone()
.unwrap_or("".to_owned())
.replace(EVICTION_NOTICE, ""),
);
ctx.trans.save_item_model(&doorstep_mut).await?;
}
}
ctx.trans.queue_for_session(
ctx.session,
Some(&format!(ansi!(
"\"Okay - let's forget this ever happened - and apart from me having a few extra credits for my trouble, your lease will continue as before!\"\n")
))).await?;
return Ok(());
}
_ => {}
}
}
}
}
let owner = corp
.as_ref()
.map(|c| format!("corp/{}", c.1.name))
.unwrap_or_else(|| player_item.refstr());
let zonecode = zone
.create_instance(
ctx.trans,
&player_item.location,
"You can only rent one apartment here, and you already have one!",
&owner,
&exit,
)
.await?;
ctx.trans
.upsert_task(&Task {
meta: TaskMeta {
task_code: format!("charge_rent/{}", &zonecode),
is_static: false,
recurrence: None, // Managed by the handler.
next_scheduled: Utc::now() + TimeDelta::try_days(1).unwrap(),
..Default::default()
},
details: TaskDetails::ChargeRoom {
zone_item: zonecode.clone(),
daily_price: rentinfo.daily_price,
},
})
.await?;
match corp.as_mut() {
None => {
let mut user_mut = user.clone();
user_mut.credits -= rentinfo.setup_fee;
ctx.trans.save_user_model(&user_mut).await?;
ctx.trans
.queue_for_session(
ctx.session,
Some(&format!(
ansi!("<yellow>Your wristpad beeps for a deduction of ${}<reset>\n"),
rentinfo.setup_fee
)),
)
.await?;
}
Some(corptup) => {
corptup.1.credits -= rentinfo.setup_fee;
ctx.trans
.update_corp_details(&corptup.0, &corptup.1)
.await?;
ctx.trans
.broadcast_to_corp(
&corptup.0,
&CorpCommType::Notice,
None,
&format!(
"[{}] {} just rented a {} for the corp for a setup fee of ${}.\n",
&corptup.1.name, &user.username, item_name, rentinfo.setup_fee
),
)
.await?;
}
}
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,41 @@
use super::{get_user_or_fail, user_error, UResult, UserVerb, UserVerbRef, VerbContext};
use ansi::ansi;
use async_trait::async_trait;
use uuid::Uuid;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
if remaining != "abuse" {
user_error(ansi!("Try <bold>report abuse<reset>.").to_owned())?;
}
let user = get_user_or_fail(ctx)?;
let username = user.username.to_lowercase();
if ctx.trans.clean_and_count_abusereports(&username).await? > 10 {
user_error(
ansi!(
"You have too many recent abuse reports logged to record any more. \
Contact staff@blastmud.org for help."
)
.to_owned(),
)?;
}
let uuid = Uuid::new_v4();
ctx.trans
.sendqueue_to_abusereport(&uuid, &username, &ctx.session)
.await?;
ctx.trans.queue_for_session(ctx.session, Some(&format!("Up to the last 80 things we sent you since you logged in will now be retained for at least 60 days under reference {}. Send an email to staff@blastmud.org explaining exactly what happened, and include this reference. We'll be able to look it up and investigate.\n", &uuid))).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,27 @@
use super::{UResult, UserVerb, UserVerbRef, VerbContext};
use crate::services::spawn::refresh_all_spawn_points;
use async_trait::async_trait;
use log::info;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
info!(
"Staff-triggered spawn reset by {}",
ctx.user_dat
.as_ref()
.map(|u| u.username.clone())
.unwrap_or_else(|| "???".to_owned())
);
refresh_all_spawn_points(&ctx.trans).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -1,61 +1,70 @@
use super::{VerbContext, UserVerb, UserVerbRef, UResult, UserError, use super::{
user_error, get_player_item_or_fail, is_likely_illegal, user_error, UResult, UserError, UserVerb,
get_player_item_or_fail, is_likely_explicit}; UserVerbRef, VerbContext,
};
#[double]
use crate::db::DBTrans;
use crate::{ use crate::{
models::item::{Item, ItemFlag}, models::item::{Item, ItemFlag},
services::broadcast_to_room, services::comms::broadcast_to_room,
db::DBTrans
}; };
use ansi::{ansi, ignore_special_characters};
use async_trait::async_trait; use async_trait::async_trait;
use ansi::{ignore_special_characters, ansi}; use mockall_double::double;
pub async fn say_to_room<'l>( pub async fn say_to_room<'l>(
trans: &DBTrans, trans: &DBTrans,
from_item: &Item, from_item: &Item,
location: &str, location: &str,
say_what: &str, say_what: &str,
is_explicit: bool
) -> UResult<()> { ) -> UResult<()> {
let (loc_type, loc_code) = location.split_once("/") let (loc_type, loc_code) = location
.split_once("/")
.ok_or_else(|| UserError("Invalid location".to_owned()))?; .ok_or_else(|| UserError("Invalid location".to_owned()))?;
let room_item = trans.find_item_by_type_code(loc_type, loc_code).await? let room_item = trans
.find_item_by_type_code(loc_type, loc_code)
.await?
.ok_or_else(|| UserError("Room missing".to_owned()))?; .ok_or_else(|| UserError("Room missing".to_owned()))?;
if room_item.flags.contains(&ItemFlag::NoSay) { if room_item.flags.contains(&ItemFlag::NoSay) {
user_error("Your wristpad vibrates and flashes up an error - apparently it has \ user_error(
been programmed to block your voice from working here.".to_owned())? "Your wristpad vibrates and flashes up an error - apparently it has \
been programmed to block your voice from working here."
.to_owned(),
)?
} }
let msg_exp = format!(
if is_likely_illegal(&say_what) {
user_error("Your message was rejected by the content filter".to_string())?;
}
let msg = format!(
ansi!("<yellow>{} says: <reset><bold>\"{}\"<reset>\n"), ansi!("<yellow>{} says: <reset><bold>\"{}\"<reset>\n"),
from_item.display_for_sentence(true, 1, true), from_item.display_for_sentence(1, true),
say_what
);
let msg_lessexp = format!(
ansi!("<yellow>{} says: <reset><bold>\"{}\"<reset>\n"),
from_item.display_for_sentence(false, 1, true),
say_what say_what
); );
broadcast_to_room( broadcast_to_room(trans, location, Some(from_item), &msg).await?;
trans,
location,
Some(from_item),
&msg_exp,
if is_explicit { None } else { Some(&msg_lessexp) }
).await?;
Ok(()) Ok(())
} }
pub struct Verb; pub struct Verb;
#[async_trait] #[async_trait]
impl UserVerb for Verb { impl UserVerb for Verb {
async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let say_what = ignore_special_characters(remaining); let say_what = ignore_special_characters(remaining);
if say_what == "" { if say_what == "" {
user_error("You need to provide a message to send.".to_owned())?; user_error("You need to provide a message to send.".to_owned())?;
} }
let player_item = get_player_item_or_fail(ctx).await?; let player_item = get_player_item_or_fail(ctx).await?;
say_to_room(ctx.trans, &player_item, &player_item.location, if player_item.death_data.is_some() {
&say_what, is_likely_explicit(&say_what)).await user_error("Shush, the dead can't talk!".to_string())?;
}
say_to_room(ctx.trans, &player_item, &player_item.location, &say_what).await
} }
} }
static VERB_INT: Verb = Verb; static VERB_INT: Verb = Verb;

View File

@ -0,0 +1,56 @@
use crate::static_content::room::room_map_by_code;
use super::{
get_player_item_or_fail, user_error, UResult, UserError, UserVerb, UserVerbRef, VerbContext,
};
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
let (loc_type, loc_code) = player_item
.location
.split_once("/")
.ok_or_else(|| UserError("Your location is invalid".to_owned()))?;
if loc_type != "room" {
user_error("You can't find anything to scan here.".to_owned())?;
}
let room = room_map_by_code()
.get(&loc_code)
.ok_or_else(|| UserError("Your location no longer exists!".to_owned()))?;
let allowed_scan = room
.scan_code
.as_ref()
.ok_or_else(|| UserError("You can't find anything to scan here.".to_owned()))?;
let user = ctx
.user_dat
.as_mut()
.ok_or(UserError("Please log in first".to_owned()))?;
if !user.scan_codes.contains(&allowed_scan) {
user.scan_codes.push(allowed_scan.clone());
ctx.trans.save_user_model(&user).await?;
}
ctx.trans
.queue_for_session(
&ctx.session,
Some("Your wristpad beeps indicating a successful scan.\n"),
)
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,134 @@
use super::{
drop::consider_expire_job_for_item, get_player_item_or_fail, user_error, UResult, UserVerb,
UserVerbRef, VerbContext,
};
use crate::{
models::item::{LocationActionType, Scavtype, SkillType},
regular_tasks::queued_command::{
queue_command_and_save, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::{
capacity::{check_item_capacity, CapacityLevel},
comms::broadcast_to_room,
skills::skill_check_and_grind,
},
};
use ansi::ansi;
use async_trait::async_trait;
use std::time;
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error(
"You try to search the area, but you realise your ghost hands aren't good for searching".to_owned(),
)?;
}
broadcast_to_room(
&ctx.trans,
&ctx.item.location,
None,
&format!(
ansi!("<blue>{} starts methodically searching the area.<reset>\n"),
ctx.item.display_for_sentence(1, true),
),
)
.await?;
Ok(time::Duration::from_secs(3))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error(
"You try to search the area, but you realise your ghost hands aren't good for searching".to_owned(),
)?;
}
let scav = ctx
.item
.total_skills
.get(&SkillType::Scavenge)
.unwrap_or(&0.0)
.clone();
let found_opt = ctx
.trans
.find_scavhidden_item_by_location(
&ctx.item.location,
&Scavtype::Scavenge,
(scav * 100.0).max(0.0) as i64,
)
.await?;
let mut found = match found_opt {
None => user_error("You have a look, and you're pretty sure there's nothing to find here. [Try searching elsewhere, or come back later]".to_owned())?,
Some(v) => v,
};
let diff: f64 = (match found.action_type {
LocationActionType::Scavhidden { difficulty, .. } => difficulty,
_ => 800,
}) as f64
/ 100.0;
if skill_check_and_grind(&ctx.trans, &mut ctx.item, &SkillType::Scavenge, diff).await? < 0.0
{
// No crit fail for now, it is either yes or no.
match ctx.get_session().await? {
None => {},
Some((sess, _)) =>
ctx.trans.queue_for_session(&sess, Some("You give up searching, but you still have a feeling there is something here.\n")).await?
}
return Ok(());
}
found.action_type = LocationActionType::Normal;
match check_item_capacity(&ctx.trans, &ctx.item, found.weight).await? {
CapacityLevel::OverBurdened | CapacityLevel::AboveItemLimit => {
consider_expire_job_for_item(&ctx.trans, &found).await?;
broadcast_to_room(
&ctx.trans,
&ctx.item.location,
None,
&format!("{} seems to have found {} - but can't carry it so drops it on the ground.\n",
ctx.item.display_for_sentence(1, true),
found.display_for_sentence(1, false),
),
).await?;
}
_ => {
broadcast_to_room(
&ctx.trans,
&ctx.item.location,
None,
&format!(
"{} seems to have found {}.\n",
ctx.item.display_for_sentence(1, true),
found.display_for_sentence(1, false),
),
)
.await?;
found.location = ctx.item.refstr();
}
}
ctx.trans.save_item_model(&found).await?;
Ok(())
}
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
queue_command_and_save(ctx, &player_item, &QueueCommand::Scavenge).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,102 @@
use super::{get_player_item_or_fail, user_error, UResult, UserVerb, UserVerbRef, VerbContext};
use crate::{
language,
models::{
item::{SkillType, StatType},
user::{wristpad_hack_data, xp_to_hack_slots},
},
};
use ansi::ansi;
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
let user = match ctx.user_dat {
None => user_error("Log in first".to_owned())?,
Some(user) => user,
};
let mut msg = String::new();
msg.push_str(&format!(
ansi!("<bgblue><white><bold>| {:11} | {:5} | {:5} |<reset>\n"),
"Stat", "Raw", "Total"
));
for st in StatType::values().iter() {
msg.push_str(&format!(
ansi!("| <bold>{:11}<reset> | {:5.2} | {:5.2} |\n"),
st.display(),
user.raw_stats.get(st).unwrap_or(&0.0),
player_item.total_stats.get(st).unwrap_or(&0.0)
));
}
msg.push_str("\n");
msg.push_str(&format!(
ansi!("<bgblue><white><bold>| {:11} | {:5} | {:5} |<reset>\n"),
"Skill", "Raw", "Total"
));
for st in SkillType::values().iter() {
msg.push_str(&format!(
ansi!("| <bold>{:11}<reset> | {:5.2} | {:5.2} |\n"),
st.display(),
user.raw_skills.get(st).unwrap_or(&0.0),
player_item.total_skills.get(st).unwrap_or(&0.0)
));
}
msg.push_str("\n");
msg.push_str(&format!(
ansi!(
"Experience: <bold>{}<reset> total, \
change of {} this re-roll, \
{} spent since re-roll.\n"
),
player_item.total_xp,
user.experience.xp_change_for_this_reroll,
user.experience.spent_xp
));
if !user.wristpad_hacks.is_empty() {
let hack_names = user
.wristpad_hacks
.iter()
.map(|h| {
wristpad_hack_data()
.get(h)
.map(|hd| hd.name)
.unwrap_or("UNKNOWN")
})
.collect::<Vec<&'static str>>();
msg.push_str(&format!(
"You have hacks installed on your wristpad: {}\n",
&language::join_words(&hack_names)
));
}
let hack_slots = xp_to_hack_slots(player_item.total_xp) as usize;
if hack_slots > user.wristpad_hacks.len() {
let free_slots = hack_slots - user.wristpad_hacks.len();
msg.push_str(&format!(
"You have {} free hack slot{} on your wristpad.\n",
free_slots,
if free_slots == 1 { "" } else { "s" }
));
} else {
msg.push_str("You have no free hack slots on your wristpad.\n");
};
ctx.trans.queue_for_session(ctx.session, Some(&msg)).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,135 @@
use super::{
get_player_item_or_fail, search_item_for_user, user_error, UResult, UserVerb, UserVerbRef,
VerbContext,
};
use crate::{
db::ItemSearchParams,
language::pluralise,
models::item::Item,
services::combat::max_health,
static_content::{
possession_type::possession_data,
room::{self, Room},
},
};
use async_trait::async_trait;
async fn check_sell_trigger(
ctx: &mut VerbContext<'_>,
player_item: &Item,
room: &Room,
sell_item: &Item,
) -> UResult<()> {
let trigger = match room.sell_trigger.as_ref() {
None => return Ok(()),
Some(tr) => tr,
};
trigger.handle_sell(ctx, room, player_item, sell_item).await
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error(
"Nobody seems to listen when you try to sell... possibly because you're dead."
.to_owned(),
)?
}
let (heretype, herecode) = player_item
.location
.split_once("/")
.unwrap_or(("room", "repro_xv_chargen"));
if heretype != "room" {
user_error("Can't sell anything because you're not in a shop.".to_owned())?;
}
let room = match room::room_map_by_code().get(herecode) {
None => user_error("Can't find that shop.".to_owned())?,
Some(r) => r,
};
if room.stock_list.is_empty() {
user_error("Can't sell anything because you're not in a shop.".to_owned())?
}
let sell_item = search_item_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
item_type_only: Some("possession"),
..ItemSearchParams::base(&player_item, remaining)
},
)
.await?;
if let Some(charge_data) = sell_item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
.and_then(|pd| pd.charge_data.as_ref())
{
if charge_data.max_charges > sell_item.charges {
user_error(format!(
"No one will want to buy a used {}!",
&sell_item.display
))?
}
}
if sell_item.health < max_health(&sell_item) {
user_error(format!(
"No one will want to buy a damaged {}!",
&sell_item.display
))?
}
for stock in &room.stock_list {
if Some(stock.possession_type.clone()) != sell_item.possession_type {
continue;
}
let stats = ctx.trans.get_location_stats(&sell_item.refstr()).await?;
if stats.total_count > 0 {
user_error("Shouldn't you empty it first?".to_owned())?;
}
let sell_discount = match stock.can_sell {
None => continue,
Some(d) => d,
};
if let Some(user) = ctx.user_dat.as_mut() {
let sell_price =
((stock.list_price as f64) * (sell_discount as f64 / 10000.0)) as u64;
user.credits += stock.list_price;
ctx.trans
.delete_item(&sell_item.item_type, &sell_item.item_code)
.await?;
ctx.trans
.queue_for_session(
&ctx.session,
Some(&format!(
"Your wristpad beeps for a credit of {} credits.\n",
sell_price
)),
)
.await?;
check_sell_trigger(ctx, &player_item, &room, &sell_item).await?;
return Ok(());
}
}
user_error(format!(
"Sorry, this store doesn't buy {}!",
pluralise(&sell_item.display)
))?
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,112 @@
use crate::{
db::ItemSearchParams,
models::item::{ConversationIntensity, ConversationalStyle},
services::sharing::{
change_conversation_intensity, change_conversation_topic, change_conversational_style,
display_conversation_status, parse_conversation_topic, start_conversation,
},
};
use super::{
get_player_item_or_fail, search_item_for_user, user_error, UResult, UserError, UserVerb,
UserVerbRef, VerbContext,
};
use ansi::ansi;
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
verb: &str,
remaining: &str,
) -> UResult<()> {
let mut player_item = (*(get_player_item_or_fail(ctx).await?)).clone();
if player_item.death_data.is_some() {
user_error("You can't do that, you're dead!".to_string())?;
}
let remaining = remaining.trim();
if let Some(ac) = player_item.active_conversation.as_ref() {
match ConversationalStyle::from_name(verb) {
None => {}
Some(style) => {
change_conversational_style(ctx, &mut player_item, style).await?;
ctx.trans.save_item_model(&player_item).await?;
return Ok(());
}
}
match parse_conversation_topic(&format!("{} {}", verb, remaining))
.map_err(|e| UserError(e.to_owned()))?
{
None => {}
Some(topic) => {
change_conversation_topic(ctx, &mut player_item, topic).await?;
ctx.trans.save_item_model(&player_item).await?;
return Ok(());
}
}
if verb == "share" {
match ConversationIntensity::from_adverb(remaining) {
None => {}
Some(intensity) => {
change_conversation_intensity(ctx, &mut player_item, intensity).await?;
ctx.trans.save_item_model(&player_item).await?;
return Ok(());
}
}
if remaining == "status" {
let (partner_type, partner_code) = ac
.partner_ref
.split_once("/")
.ok_or_else(|| UserError("Bad share partner".to_owned()))?;
if let Some(partner) = ctx
.trans
.find_item_by_type_code(partner_type, partner_code)
.await?
{
display_conversation_status(&ctx.trans, &player_item, &partner).await?;
return Ok(());
}
}
}
user_error("You're already sharing knowledge!".to_owned())?;
}
let (word2, remaining) = remaining.split_once(" ").ok_or_else(|| {
UserError(
ansi!("Start your encounter with the <bold>share knowledge with<reset> command first.")
.to_owned(),
)
})?;
let (word3, remaining) = remaining.trim().split_once(" ").ok_or_else(|| {
UserError(ansi!("<bold>share knowledge with<reset> command first.").to_owned())
})?;
if verb != "share" || word2.trim() != "knowledge" || word3.trim() != "with" {
user_error(
ansi!("Start your encounter with the <bold>share knowledge with<reset> command first.")
.to_owned(),
)?;
}
let with_whom = search_item_for_user(
ctx,
&ItemSearchParams {
include_loc_contents: true,
limit: 1,
..ItemSearchParams::base(&player_item, remaining)
},
)
.await?;
start_conversation(&ctx.trans, &player_item, &with_whom).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,49 @@
use super::{
get_player_item_or_fail, search_item_for_user, user_error, UResult, UserVerb, UserVerbRef,
VerbContext,
};
use crate::{db::ItemSearchParams, static_content::possession_type::possession_data};
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let item_name = remaining.trim();
if item_name == "" {
user_error("Sign what? Try: sign something".to_owned())?;
}
let player_item = get_player_item_or_fail(ctx).await?;
let item = search_item_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
..ItemSearchParams::base(&player_item, item_name)
},
)
.await?;
if item.item_type != "possession" {
user_error("You can't sign that!".to_owned())?;
}
let handler = match item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
.and_then(|pd| pd.sign_handler)
{
None => user_error("You can't sign that!".to_owned())?,
Some(h) => h,
};
handler.cmd(ctx, &player_item, &item).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,198 @@
use super::{
get_player_item_or_fail, search_item_for_user, user_error, ItemSearchParams, UResult, UserVerb,
UserVerbRef, VerbContext,
};
use crate::{
models::item::LocationActionType,
regular_tasks::queued_command::{
queue_command_and_save, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::{comms::broadcast_to_room, urges::recalculate_urge_growth},
};
use async_trait::async_trait;
use std::time;
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error("You try to sit, but it turns out the dead can't even do that!".to_owned())?;
}
let item_ref = match ctx.command {
QueueCommand::Sit { item } => item,
_ => user_error("Unexpected command".to_owned())?,
};
if ctx
.item
.active_combat
.as_ref()
.and_then(|ac| ac.attacking.as_ref())
.is_some()
{
user_error(
"Sit... while fighting? You can't figure out how to make it work".to_owned(),
)?
}
if ctx.item.active_climb.is_some() {
user_error(
"Sit... while climbing? You can't figure out how to make it work".to_owned(),
)?
}
match ctx.item.action_type {
LocationActionType::Sitting { .. } => user_error("You're already sitting.".to_owned())?,
_ => {}
}
match item_ref {
None => {}
Some(item_ref) => {
let (item_type, item_code) = match item_ref.split_once("/") {
None => user_error("Invalid item ref in Sit command".to_owned())?,
Some(v) => v,
};
match ctx
.trans
.find_item_by_type_code(&item_type, &item_code)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(item) => {
if item.location != ctx.item.location {
user_error(format!(
"You try to sit on {} but realise it's no longer here",
item.display_for_sentence(1, false)
))?
}
}
}
}
};
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error("You try to sit, but it turns out the dead can't even do that!".to_owned())?;
}
let item_ref = match ctx.command {
QueueCommand::Sit { item } => item,
_ => user_error("Unexpected command".to_owned())?,
};
if ctx
.item
.active_combat
.as_ref()
.and_then(|ac| ac.attacking.as_ref())
.is_some()
{
user_error(
"Sit... while fighting? You can't figure out how to make it work".to_owned(),
)?
}
if ctx.item.active_climb.is_some() {
user_error(
"Sit... while climbing? You can't figure out how to make it work".to_owned(),
)?
}
match ctx.item.action_type {
LocationActionType::Sitting { .. } => user_error("You're already sitting.".to_owned())?,
_ => {}
}
let (item, desc) = match item_ref {
None => (None, "the floor".to_owned()),
Some(item_ref) => {
let (item_type, item_code) = match item_ref.split_once("/") {
None => user_error("Invalid item ref in Sit command".to_owned())?,
Some(v) => v,
};
match ctx
.trans
.find_item_by_type_code(&item_type, &item_code)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(item) => {
if item.location != ctx.item.location {
user_error(format!(
"You try to sit on {} but realise it's no longer here",
item.display_for_sentence(1, false)
))?
}
(Some(item.clone()), item.display_for_sentence(1, false))
}
}
}
};
let msg = format!(
"{} sits on {}.\n",
&ctx.item.display_for_sentence(1, true),
&desc
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
ctx.item.action_type = LocationActionType::Sitting(item.map(|it| it.refstr()));
recalculate_urge_growth(&ctx.trans, &mut ctx.item).await?;
Ok(())
}
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
mut remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
if remaining.starts_with("on ") {
remaining = remaining[3..].trim();
}
if remaining == "" {
remaining = "floor";
}
let target = if remaining == "here" || remaining == "ground" || remaining == "floor" {
None
} else {
Some(
search_item_for_user(
ctx,
&ItemSearchParams {
include_loc_contents: true,
..ItemSearchParams::base(&player_item, &remaining)
},
)
.await?,
)
};
if player_item.death_data.is_some() {
user_error("You try to sit, but it turns out the dead can't even do that!".to_owned())?;
}
if let Some(target) = target.as_ref() {
if target
.static_data()
.and_then(|pd| pd.sit_data.as_ref())
.is_none()
{
user_error("You can't sit on that!".to_owned())?;
}
}
queue_command_and_save(
ctx,
&player_item,
&QueueCommand::Sit {
item: target.map(|t| t.refstr()),
},
)
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,52 @@
use super::{
get_player_item_or_fail, user_error, UResult, UserError, UserVerb, UserVerbRef, VerbContext,
};
use crate::static_content::room;
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let requester = get_player_item_or_fail(ctx).await?;
let mut resp: String = String::new();
if remaining == "loc" {
let (loc_type, loc_code) = requester
.location
.split_once("/")
.ok_or_else(|| UserError("Invalid current location string".to_owned()))?;
resp.push_str(&format!("Location code: {}\n", loc_code));
if loc_type == "room" {
resp.push_str("You're in an ordinary room.\n");
let room = room::room_map_by_code().get(loc_code).ok_or_else(|| {
UserError("Can't find your current room in static data".to_owned())
})?;
resp.push_str(&format!(
"Grid coords: x={}, y={}, z={}. Coordinates get lower as you go \
north, west, down.\n",
room.grid_coords.x, room.grid_coords.y, room.grid_coords.z
));
} else if loc_type == "dynroom" {
resp.push_str("You're in a dynamic room.\n");
} else {
resp.push_str(&format!(
"Weird - you're in a location of type {}.\n",
loc_type
));
}
} else {
user_error("Unknown subcommand. Try loc.".to_owned())?;
}
ctx.trans
.queue_for_session(&ctx.session, Some(&resp))
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,90 @@
use super::{get_player_item_or_fail, user_error, UResult, UserVerb, UserVerbRef, VerbContext};
#[double]
use crate::db::DBTrans;
use crate::{
models::item::{Item, LocationActionType},
regular_tasks::queued_command::{
queue_command_and_save, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::{comms::broadcast_to_room, urges::recalculate_urge_growth},
};
use async_trait::async_trait;
use mockall_double::double;
use std::time;
pub async fn stand_if_needed(trans: &DBTrans, who: &mut Item) -> UResult<()> {
match who.action_type {
LocationActionType::Sitting { .. } | LocationActionType::Reclining { .. } => {}
_ => return Ok(()),
}
let msg = format!("{} stands up.\n", &who.display_for_sentence(1, true));
broadcast_to_room(trans, &who.location, None, &msg).await?;
who.action_type = LocationActionType::Normal;
recalculate_urge_growth(trans, who).await?;
Ok(())
}
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error(
"You try to stand, but it turns out the dead can't even do that!".to_owned(),
)?;
}
match ctx.command {
QueueCommand::Stand => {}
_ => user_error("Unexpected command".to_owned())?,
};
match ctx.item.action_type {
LocationActionType::Sitting { .. } | LocationActionType::Reclining { .. } => {}
_ => user_error("You're already standing.".to_owned())?,
}
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error(
"You try to stand, but it turns out the dead can't even do that!".to_owned(),
)?;
}
match ctx.command {
QueueCommand::Stand => {}
_ => user_error("Unexpected command".to_owned())?,
};
match ctx.item.action_type {
LocationActionType::Sitting { .. } | LocationActionType::Reclining { .. } => {}
_ => user_error("You're already standing.".to_owned())?,
}
stand_if_needed(&ctx.trans, &mut ctx.item).await?;
Ok(())
}
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error(
"You try to stand, but it turns out the dead can't even do that!".to_owned(),
)?;
}
queue_command_and_save(ctx, &player_item, &QueueCommand::Stand).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,81 @@
use super::{get_player_item_or_fail, user_error, UResult, UserVerb, UserVerbRef, VerbContext};
use crate::{
models::item::Urges,
services::{combat::max_health, display::bar_n_of_m},
};
use ansi::ansi;
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
let user = match ctx.user_dat {
None => user_error("Log in first".to_owned())?,
Some(user) => user,
};
let mut msg = String::new();
let maxh = max_health(&player_item);
msg.push_str(&format!(
ansi!("<bold>Health [<green>{}<reset><bold>] [ {}/{} ]<reset>\n"),
bar_n_of_m(player_item.health, maxh),
player_item.health,
maxh
));
let (hunger, thirst, stress) = match player_item.urges.as_ref() {
None => (0, 0, 0),
Some(Urges {
hunger,
thirst,
stress,
}) => (hunger.value, thirst.value, stress.value),
};
msg.push_str(&format!(
ansi!("<bold>Hunger [<green>{}<reset><bold>] [ {}/{} ]<reset>\n"),
bar_n_of_m((hunger / 200) as u64, 50),
hunger / 100,
100
));
msg.push_str(&format!(
ansi!("<bold>Thirst [<green>{}<reset><bold>] [ {}/{} ]<reset>\n"),
bar_n_of_m((thirst / 200) as u64, 50),
thirst / 100,
100
));
msg.push_str(&format!(
ansi!("<bold>Stress [<green>{}<reset><bold>] [ {}/{} ]<reset>\n"),
bar_n_of_m((stress / 200) as u64, 50),
stress / 100,
100
));
msg.push_str(&format!(
ansi!("<bold>Credits <green>${}<reset>\n"),
user.credits
));
ctx.trans.queue_for_session(ctx.session, Some(&msg)).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;
#[cfg(test)]
mod test {
#[test]
fn bar_n_of_m_works() {
assert_eq!(super::bar_n_of_m(4, 7), "|||| ");
assert_eq!(super::bar_n_of_m(8, 7), "|||||||");
assert_eq!(super::bar_n_of_m(8, 8), "||||||||");
assert_eq!(super::bar_n_of_m(0, 5), " ");
}
}

View File

@ -0,0 +1,75 @@
use super::{
get_player_item_or_fail, movement::reverse_climb, user_error, UResult, UserVerb, UserVerbRef,
VerbContext,
};
use crate::services::sharing::stop_conversation_mut;
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error("You're dead. If you wanted to just stop being dead, I'm afraid it doesn't work like that (although you can try going up to resurrect into a new body). If you wanted to stop doing whatever you were doing when you died, then the good news is that your death already put a stop to it.".to_owned())?;
}
let mut player_item_mut = (*player_item).clone();
let mut queue_head = player_item_mut.queue.pop_front();
if player_item_mut.active_conversation.is_some() {
stop_conversation_mut(
&ctx.trans,
&mut player_item_mut,
&format!(
"holds up {} hand to stop the conversation with",
&player_item.pronouns.possessive
),
)
.await?;
ctx.trans.save_item_model(&player_item_mut).await?;
return Ok(());
}
if player_item.active_combat.is_some() {
// Otherwise, we assume they wanted to stop escaping etc...
if queue_head.is_none() {
user_error("You can't just stop fighting - either fight to the death or try moving to another room.".to_owned())?;
}
}
let mut msg = String::new();
if let Some(_active_climb) = player_item_mut.active_climb.as_mut() {
if let Some(ref mut queue_head_v) = queue_head {
msg.push_str(&reverse_climb(&mut player_item_mut, &ctx.trans, queue_head_v).await?);
}
}
let qlen = player_item_mut.queue.len();
if qlen > 0 {
msg.push_str(&format!(
"You cancel your plans to take {} action{}.\n",
qlen,
if qlen > 1 { "s" } else { "" },
));
} else if msg == "" {
user_error("You weren't doing anything that could be stopped.".to_owned())?;
}
player_item_mut.queue = queue_head.clone().into_iter().collect();
ctx.trans.save_item_model(&player_item_mut).await?;
ctx.trans
.queue_for_session(&ctx.session, Some(&msg))
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,73 @@
use crate::{db::ItemSearchParams, static_content::possession_type::possession_data};
use super::{
get_player_item_or_fail, search_item_for_user, user_error, UResult, UserError, UserVerb,
UserVerbRef, VerbContext,
};
use ansi::ansi;
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error("You can't figure out to turn things on/off whilst dead.".to_owned())?;
}
let (state, item_name) = remaining.split_once(" ").ok_or_else(|| {
UserError(
ansi!("Try <bold>turn on something<reset> or <bold>turn off something<reset>.")
.to_owned(),
)
})?;
let state = state.trim().to_lowercase();
let to_state = if state == "on" {
true
} else if state == "off" {
false
} else {
user_error(
ansi!("Try <bold>turn on something<reset> or <bold>turn off something<reset>.")
.to_owned(),
)?
};
let item_name = item_name.trim();
let item = search_item_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
include_loc_contents: true,
limit: 1,
..ItemSearchParams::base(&player_item, item_name)
},
)
.await?;
let handler = item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
.and_then(|pd| pd.turn_toggle_handler)
.ok_or_else(|| {
UserError(format!(
"You can't turn that {}!",
if to_state { "on" } else { "off" }
))
})?;
handler.turn_cmd(ctx, &player_item, &item, to_state).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,95 @@
use super::{
get_player_item_or_fail, search_items_for_user, user_error, UResult, UserError, UserVerb,
UserVerbRef, VerbContext,
};
use crate::{
db::ItemSearchParams,
models::item::ItemFlag,
static_content::{possession_type::possession_data, room::Direction},
};
use ansi::ansi;
use async_trait::async_trait;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let (uninstall_what_raw, what_dir_raw) = match remaining.rsplit_once(" from door to ") {
None => user_error(ansi!("Uninstall from where? Try <bold>uninstall<reset> <lt>lock> <bold>from door to<reset> <lt>direction>").to_owned())?,
Some(v) => v
};
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error(
"Apparently, you have to be alive to work as an uninstaller. \
So discriminatory!"
.to_owned(),
)?;
}
let (loc_t, loc_c) = player_item
.location
.split_once("/")
.ok_or_else(|| UserError("Invalid current location".to_owned()))?;
let loc_item = ctx
.trans
.find_item_by_type_code(loc_t, loc_c)
.await?
.ok_or_else(|| UserError("Can't find your location".to_owned()))?;
if loc_item.owner.as_ref() != Some(&player_item.refstr())
|| !loc_item.flags.contains(&ItemFlag::PrivatePlace)
{
user_error(
"You can only uninstall things while standing in a private room you own. \
If you are outside, try uninstalling from the inside."
.to_owned(),
)?;
}
let dir = Direction::parse(what_dir_raw.trim())
.ok_or_else(|| UserError("Invalid direction.".to_owned()))?;
let cand_items = search_items_for_user(
ctx,
&ItemSearchParams {
include_loc_contents: true,
..ItemSearchParams::base(&player_item, uninstall_what_raw.trim())
},
)
.await?;
let item = cand_items
.iter()
.find(|it| it.action_type.is_in_direction(&dir))
.ok_or_else(|| {
UserError(
"Sorry, I couldn't find anything matching installed on that door.".to_owned(),
)
})?;
if item.item_type != "possession" {
user_error("You can't uninstall that!".to_owned())?;
}
let handler = match item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
.and_then(|pd| pd.install_handler)
{
None => user_error("You can't uninstall that!".to_owned())?,
Some(h) => h,
};
handler
.uninstall_cmd(ctx, &player_item, &item, &loc_item, &dir)
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,357 @@
use super::{
get_player_item_or_fail, parsing, search_item_for_user, user_error, ItemSearchParams, UResult,
UserVerb, UserVerbRef, VerbContext,
};
use crate::{
language,
models::item::SkillType,
regular_tasks::queued_command::{
queue_command_and_save, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::{
check_consent, comms::broadcast_to_room, effect::run_effects, skills::skill_check_and_grind,
},
static_content::possession_type::possession_data,
};
use async_trait::async_trait;
use std::sync::Arc;
use std::time;
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error(
"You try to use it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let (item_id, target_type_code) = match ctx.command {
QueueCommand::Use {
possession_id,
target_id,
} => (possession_id, target_id),
_ => user_error("Unexpected command".to_owned())?,
};
let item = match ctx
.trans
.find_item_by_type_code("possession", &item_id)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != format!("player/{}", ctx.item.item_code) {
user_error(format!(
"You try to use {} but realise you no longer have it",
item.display_for_sentence(1, false)
))?
}
let (target_type, target_code) = match target_type_code.split_once("/") {
None => user_error("Couldn't handle use command (invalid target)".to_owned())?,
Some(spl) => spl,
};
let is_self_use = target_type == "player" && target_code == ctx.item.item_code;
let target = if is_self_use {
Arc::new(ctx.item.clone())
} else {
match ctx
.trans
.find_item_by_type_code(&target_type, &target_code)
.await?
{
None => user_error(format!(
"Couldn't handle use command (target {} missing)",
target_type_code
))?,
Some(it) => it,
}
};
if !is_self_use
&& target.location != ctx.item.location
&& target.location != format!("player/{}", ctx.item.item_code)
{
let target_name = target.display_for_sentence(1, false);
user_error(format!(
"You try to use {} on {}, but realise {} is no longer here",
item.display_for_sentence(1, false),
target_name,
target_name
))?
}
let msg = format!(
"{} prepares to use {} {} on {}\n",
&ctx.item.display_for_sentence(1, true),
&ctx.item.pronouns.possessive,
&item.display_for_sentence(0, false),
&if is_self_use {
ctx.item.pronouns.intensive.clone()
} else {
target.display_for_sentence(1, false)
}
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
let mut draw_level: f64 = *ctx
.item
.total_skills
.get(&SkillType::Quickdraw)
.to_owned()
.unwrap_or(&8.0);
let skill_result =
skill_check_and_grind(ctx.trans, ctx.item, &SkillType::Quickdraw, draw_level).await?;
if skill_result < -0.5 {
draw_level -= 2.0;
} else if skill_result < -0.25 {
draw_level -= 1.0;
} else if skill_result > 0.5 {
draw_level += 2.0;
} else if skill_result > 0.25 {
draw_level += 1.0;
}
let wait_ticks = (12.0 - (draw_level / 2.0)).min(8.0).max(1.0);
Ok(time::Duration::from_millis(
(wait_ticks * 500.0).round() as u64
))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error(
"You try to use it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let (ref item_id, ref target_type_code) = match ctx.command {
QueueCommand::Use {
possession_id,
target_id,
} => (possession_id, target_id),
_ => user_error("Unexpected command".to_owned())?,
};
let item = match ctx
.trans
.find_item_by_type_code("possession", &item_id)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != format!("player/{}", ctx.item.item_code) {
user_error(format!(
"You try to use {} but realise you no longer have it",
item.display_for_sentence(1, false)
))?
}
let (ref target_type, ref target_code) = match target_type_code.split_once("/") {
None => user_error("Couldn't handle use command (invalid target)".to_owned())?,
Some(ref sp) => sp.clone(),
};
let target = match ctx
.trans
.find_item_by_type_code(&target_type, &target_code)
.await?
{
None => user_error("Couldn't handle use command (target missing)".to_owned())?,
Some(it) => it,
};
if target.location != ctx.item.location
&& target.location != format!("player/{}", ctx.item.item_code)
{
let target_name = target.display_for_sentence(1, false);
user_error(format!(
"You try to use {} on {}, but realise {} is no longer here",
item.display_for_sentence(1, false),
target_name,
target_name
))?
}
let use_data = match item
.possession_type
.as_ref()
.and_then(|poss_type| possession_data().get(&poss_type))
.and_then(|poss_data| poss_data.use_data.as_ref())
{
None => user_error("You can't use that!".to_owned())?,
Some(d) => d,
};
if let Some(consent_type) = use_data.needs_consent_check.as_ref() {
if !check_consent(ctx.trans, "use", consent_type, &ctx.item, &target).await? {
user_error(format!(
"{} doesn't allow {} from you",
&target.display_for_sentence(1, true),
consent_type.to_str()
))?
}
}
if let Some(charge_data) = item
.possession_type
.as_ref()
.and_then(|poss_type| possession_data().get(&poss_type))
.and_then(|poss_data| poss_data.charge_data.as_ref())
{
if item.charges < 1 {
user_error(format!(
"{} has no {} {} left",
item.display_for_sentence(1, true),
&language::pluralise(charge_data.charge_name_prefix),
charge_data.charge_name_suffix
))?;
}
}
if let Some(err) = (use_data.errorf)(&item, &target) {
user_error(err)?;
}
let is_self_use = target_type == &"player" && target_code == &ctx.item.item_code;
let skillcheck = skill_check_and_grind(
&ctx.trans,
ctx.item,
&use_data.uses_skill,
use_data.diff_level,
)
.await?;
let (effects, skilllvl) = if skillcheck <= -0.5 {
// 0-1 how bad was the crit fail?
(&use_data.crit_fail_effects, (-0.5 - skillcheck) * 2.0)
} else if skillcheck < 0.0 {
(&use_data.fail_effects, -skillcheck * 2.0)
} else {
(&use_data.success_effects, skillcheck)
};
let mut target_mut = if is_self_use {
None
} else {
Some((*target).clone())
};
if let Some(actual_effects) = effects {
run_effects(
ctx.trans,
&actual_effects,
ctx.item,
&item,
target_mut.as_mut(),
skilllvl,
)
.await?;
}
if let Some(target_mut_save) = target_mut {
ctx.trans.save_item_model(&target_mut_save).await?;
}
let mut item_mut = (*item).clone();
let mut save_item = false;
if item
.possession_type
.as_ref()
.and_then(|poss_type| possession_data().get(&poss_type))
.and_then(|poss_data| poss_data.charge_data.as_ref())
.is_some()
{
item_mut.charges -= 1;
save_item = true;
}
if item_mut.charges == 0 {
if let Some((new_poss, new_poss_dat)) = item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(pt))
.and_then(|poss_data| poss_data.becomes_on_spent.as_ref())
.and_then(|poss_type| {
possession_data()
.get(&poss_type)
.map(|poss_dat| (poss_type, poss_dat))
})
{
item_mut.possession_type = Some(new_poss.clone());
item_mut.display = new_poss_dat.display.to_owned();
item_mut.details = Some(new_poss_dat.details.to_owned());
item_mut.aliases = new_poss_dat
.aliases
.iter()
.map(|al| (*al).to_owned())
.collect();
item_mut.health = new_poss_dat.max_health;
item_mut.weight = new_poss_dat.weight;
save_item = true;
}
}
if save_item {
ctx.trans.save_item_model(&item_mut).await?;
}
Ok(())
}
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
if player_item.death_data.is_some() {
user_error(
"You try to use it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let (what_name, whom_name) = parsing::parse_on_or_default(remaining, "me");
let item = search_item_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
item_type_only: Some("possession"),
limit: 1,
..ItemSearchParams::base(&player_item, &what_name)
},
)
.await?;
let target = if whom_name == "me" || whom_name == "self" {
player_item.clone()
} else {
search_item_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
include_loc_contents: true,
limit: 1,
..ItemSearchParams::base(&player_item, &whom_name)
},
)
.await?
};
let use_data = match item
.possession_type
.as_ref()
.and_then(|poss_type| possession_data().get(&poss_type))
.and_then(|poss_data| poss_data.use_data.as_ref())
{
None => user_error("You can't use that!".to_owned())?,
Some(d) => d,
};
if let Some(err) = (use_data.errorf)(&item, &target) {
user_error(err)?;
}
queue_command_and_save(
ctx,
&player_item,
&QueueCommand::Use {
possession_id: item.item_code.clone(),
target_id: format!("{}/{}", target.item_type, target.item_code),
},
)
.await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,145 @@
use super::{
corp::check_corp_perm, get_player_item_or_fail, user_error, UResult, UserError, UserVerb,
UserVerbRef, VerbContext,
};
use crate::{
models::{
corp::{CorpCommType, CorpPermission},
item::{Item, ItemSpecialData},
},
static_content::room::{room_map_by_code, Direction, RentSuiteType},
};
use ansi::ansi;
use async_trait::async_trait;
use chrono::{TimeDelta, Utc};
use itertools::Itertools;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let remaining = remaining.trim();
let (item_name, corp_name) = match remaining.split_once(" for ") {
None => (remaining, None),
Some((i, c)) => (i.trim(), Some(c.trim())),
};
let player_item = get_player_item_or_fail(ctx).await?;
let (loc_type, loc_code) = player_item
.location
.split_once("/")
.ok_or_else(|| UserError("Invalid location".to_owned()))?;
if loc_type != "room" {
user_error(
"You must go to where you rented the place (e.g. reception) to vacate.".to_owned(),
)?;
}
let room = room_map_by_code()
.get(loc_code)
.ok_or_else(|| UserError("Can't find your room".to_owned()))?;
if room.rentable_dynzone.is_empty() {
user_error("Go to where you rented the place (e.g. reception) to vacate.".to_owned())?;
}
let rentinfo = match room
.rentable_dynzone
.iter()
.find(|ri| ri.rent_what == item_name)
{
None => user_error(format!(
"Vacate must be followed by the specific thing you want to vacate: {}",
room.rentable_dynzone
.iter()
.map(|ri| ri.rent_what.as_str())
.join(", ")
))?,
Some(v) => v,
};
let corp = match (&rentinfo.suite_type, corp_name) {
(RentSuiteType::Commercial, None) =>
user_error(format!(
ansi!("This is a commercial suite, you need to vacate it using the name of the corp. Try <bold>vacate {} for corpname<reset>"),
item_name
))?,
(RentSuiteType::Residential, Some(_)) =>
user_error("This is a residential suite, you can't vacate it for a corp. Try <bold>vacate {}<reset>".to_owned())?,
(RentSuiteType::Residential, None) => None,
(RentSuiteType::Commercial, Some(n)) => match ctx.trans.match_user_corp_by_name(&n, &player_item.item_code).await? {
None => user_error("I can't find that corp in your list of corps!".to_owned())?,
Some((_, _, mem)) if !check_corp_perm(&CorpPermission::Holder, &mem) => user_error("You don't have holder permissions in that corp.".to_owned())?,
Some((corp_id, corp, _)) => Some((corp_id, corp))
},
};
let exit = Direction::IN {
item: corp
.as_ref()
.map(|c| c.1.name.clone())
.unwrap_or_else(|| player_item.display.clone()),
};
match ctx
.trans
.find_exact_dyn_exit(&player_item.location, &exit)
.await?
.as_ref()
.and_then(|it| it.location.split_once("/"))
{
None => user_error("You aren't renting anything from here!".to_owned())?,
Some((ref ex_zone_t, ref ex_zone_c)) => {
if let Some(ex_zone) = ctx
.trans
.find_item_by_type_code(ex_zone_t, ex_zone_c)
.await?
{
match ex_zone.special_data {
Some(ItemSpecialData::DynzoneData {
vacate_after: Some(_),
..
}) => user_error("Your lease is already up for termination.".to_owned())?,
Some(ItemSpecialData::DynzoneData {
vacate_after: None,
zone_exit: ref ex,
}) => {
ctx.trans
.save_item_model(&Item {
special_data: Some(ItemSpecialData::DynzoneData {
zone_exit: ex.clone(),
vacate_after: Some(
Utc::now() + TimeDelta::try_days(1).unwrap(),
),
}),
..(*ex_zone).clone()
})
.await?;
ctx.trans.queue_for_session(ctx.session, Some("The robot files away your notice of intention to vacate. \"You have 24 hours to get all your stuff out, then the landlord will send someone up to boot out anyone still in there, and we will sell anything left behind to cover our costs. If you change your mind before then, just rent again and we'll cancel out your notice and let you keep the same apartment - then you'll have to pay the setup fee again though.\"\n")).await?;
match corp {
None => {},
Some(corptup) =>
ctx.trans.broadcast_to_corp(
&corptup.0,
&CorpCommType::Notice,
Some(&player_item.item_code),
&format!(
ansi!("<cyan>[{}] {} just gave notice to vacate {}! The landlord replied with: \"You have 24 hours to get all your stuff out, then the landlord will send someone up to boot out anyone still in there, and we will sell anything left behind to cover our costs. If you change your mind before then, just rent again and we'll cancel out your notice and let you keep the same apartment - then you'll have to pay the setup fee again though.\"<reset>\n"),
&corptup.1.name,
&player_item.display_for_sentence(1, false),
&ex_zone.display
)
).await?
}
}
_ => user_error("The premises seem to be broken anyway".to_owned())?,
}
}
}
}
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -0,0 +1,237 @@
use super::{
get_player_item_or_fail, parsing::parse_count, search_items_for_user, user_error,
ItemSearchParams, UResult, UserError, UserVerb, UserVerbRef, VerbContext,
};
use crate::{
models::item::{Buff, BuffCause, BuffImpact, LocationActionType, SkillType},
regular_tasks::queued_command::{
queue_command, QueueCommand, QueueCommandHandler, QueuedCommandContext,
},
services::{comms::broadcast_to_room, skills::calculate_total_stats_skills_for_user},
static_content::possession_type::possession_data,
};
use async_trait::async_trait;
use chrono::Utc;
use std::time;
pub struct QueueHandler;
#[async_trait]
impl QueueCommandHandler for QueueHandler {
async fn start_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<time::Duration> {
if ctx.item.death_data.is_some() {
user_error(
"You try to wear it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let item_id = match ctx.command {
QueueCommand::Wear { possession_id } => possession_id,
_ => user_error("Unexpected command".to_owned())?,
};
let item = match ctx
.trans
.find_item_by_type_code("possession", &item_id)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != ctx.item.refstr() {
user_error(format!(
"You try to wear {} but realise you no longer have it",
item.display_for_sentence(1, false)
))?
}
if item.action_type == LocationActionType::Worn {
user_error("You realise you're already wearing it!".to_owned())?;
}
let poss_data = item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(&pt))
.ok_or_else(|| {
UserError("That item no longer exists in the game so can't be handled".to_owned())
})?;
poss_data
.wear_data
.as_ref()
.ok_or_else(|| UserError("You can't wear that!".to_owned()))?;
let msg = format!(
"{} fumbles around trying to put on {}\n",
&ctx.item.display_for_sentence(1, true),
&item.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
Ok(time::Duration::from_secs(1))
}
#[allow(unreachable_patterns)]
async fn finish_command(&self, ctx: &mut QueuedCommandContext<'_>) -> UResult<()> {
if ctx.item.death_data.is_some() {
user_error(
"You try to wear it, but your ghostly hands slip through it uselessly".to_owned(),
)?;
}
let item_id = match ctx.command {
QueueCommand::Wear { possession_id } => possession_id,
_ => user_error("Unexpected command".to_owned())?,
};
let item = match ctx
.trans
.find_item_by_type_code("possession", &item_id)
.await?
{
None => user_error("Item not found".to_owned())?,
Some(it) => it,
};
if item.location != ctx.item.refstr() {
user_error(format!(
"You try to wear {} but realise it is no longer there.",
&item.display_for_sentence(1, false)
))?
}
if item.action_type == LocationActionType::Worn {
user_error("You realise you're already wearing it!".to_owned())?;
}
let poss_data = item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(&pt))
.ok_or_else(|| {
UserError("That item no longer exists in the game so can't be handled".to_owned())
})?;
let wear_data = poss_data
.wear_data
.as_ref()
.ok_or_else(|| UserError("You can't wear that!".to_owned()))?;
let other_clothes = ctx
.trans
.find_by_action_and_location(&ctx.item.refstr(), &LocationActionType::Worn)
.await?;
for part in &wear_data.covers_parts {
let thickness: f64 =
other_clothes
.iter()
.fold(wear_data.thickness, |tot, other_item| {
match other_item
.possession_type
.as_ref()
.and_then(|pt| possession_data().get(&pt))
.and_then(|pd| pd.wear_data.as_ref())
{
Some(wd) if wd.covers_parts.contains(&part) => tot + wd.thickness,
_ => tot,
}
});
if thickness > 12.0 {
user_error(format!(
"You're wearing too much on your {} already.",
part.display(ctx.item.sex.clone())
))?;
}
}
let msg = format!(
"{} wears {}\n",
&ctx.item.display_for_sentence(1, true),
&item.display_for_sentence(1, false)
);
broadcast_to_room(ctx.trans, &ctx.item.location, None, &msg).await?;
let mut item_mut = (*item).clone();
item_mut.action_type = LocationActionType::Worn;
item_mut.action_type_started = Some(Utc::now());
if wear_data.dodge_penalty != 0.0 {
ctx.item.temporary_buffs.push(Buff {
description: "Dodge penalty".to_owned(),
code: "dodge".to_owned(),
cause: BuffCause::ByItem {
item_type: item_mut.item_type.clone(),
item_code: item_mut.item_code.clone(),
},
impacts: vec![BuffImpact::ChangeSkill {
skill: SkillType::Dodge,
magnitude: -wear_data.dodge_penalty,
}],
});
if ctx.item.item_type == "player" {
if let Some(usr) = ctx.trans.find_by_username(&ctx.item.item_code).await? {
calculate_total_stats_skills_for_user(ctx.item, &usr);
}
}
}
ctx.trans.save_item_model(&item_mut).await?;
Ok(())
}
}
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
mut remaining: &str,
) -> UResult<()> {
let player_item = get_player_item_or_fail(ctx).await?;
let mut get_limit = Some(1);
if remaining == "all" || remaining.starts_with("all ") {
remaining = remaining[3..].trim();
get_limit = None;
} else if let (Some(n), remaining2) = parse_count(remaining) {
get_limit = Some(n);
remaining = remaining2;
}
let targets = search_items_for_user(
ctx,
&ItemSearchParams {
include_contents: true,
item_type_only: Some("possession"),
limit: get_limit.unwrap_or(100),
item_action_type_only: Some(&LocationActionType::Normal),
..ItemSearchParams::base(&player_item, &remaining)
},
)
.await?;
if player_item.death_data.is_some() {
user_error("The dead don't dress themselves".to_owned())?;
}
let mut did_anything: bool = false;
let mut player_item_mut = (*player_item).clone();
for target in targets
.iter()
.filter(|t| t.action_type.is_visible_in_look())
{
if target.item_type != "possession" {
user_error("You can't wear that!".to_owned())?;
}
did_anything = true;
queue_command(
ctx,
&mut player_item_mut,
&QueueCommand::Wear {
possession_id: target.item_code.clone(),
},
)
.await?;
}
if !did_anything {
user_error("I didn't find anything matching.".to_owned())?;
} else {
ctx.trans.save_item_model(&player_item_mut).await?;
}
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

View File

@ -1,39 +1,60 @@
use super::{VerbContext, UserVerb, UserVerbRef, UResult, use super::{
ItemSearchParams, user_error, get_player_item_or_fail, is_likely_illegal, parsing::parse_to_space, search_item_for_user,
get_player_item_or_fail, is_likely_explicit, user_error, ItemSearchParams, UResult, UserVerb, UserVerbRef, VerbContext,
search_item_for_user, };
parsing::parse_to_space};
use crate::static_content::npc::npc_by_code; use crate::static_content::npc::npc_by_code;
use ansi::{ansi, ignore_special_characters};
use async_trait::async_trait; use async_trait::async_trait;
use ansi::{ignore_special_characters, ansi};
pub struct Verb; pub struct Verb;
#[async_trait] #[async_trait]
impl UserVerb for Verb { impl UserVerb for Verb {
async fn handle(self: &Self, ctx: &mut VerbContext, _verb: &str, remaining: &str) -> UResult<()> { async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
remaining: &str,
) -> UResult<()> {
let (to_whom_name, say_what_raw) = parse_to_space(remaining); let (to_whom_name, say_what_raw) = parse_to_space(remaining);
let say_what = ignore_special_characters(say_what_raw); let say_what = ignore_special_characters(say_what_raw);
if say_what == "" { if say_what == "" {
user_error("You need to provide a message to send.".to_owned())?; user_error("You need to provide a message to send.".to_owned())?;
} }
let player_item = get_player_item_or_fail(ctx).await?; let player_item = get_player_item_or_fail(ctx).await?;
let to_whom = search_item_for_user(ctx, &ItemSearchParams { if player_item.death_data.is_some() {
include_loc_contents: true, user_error("Shush, the dead can't talk!".to_string())?;
..ItemSearchParams::base(&player_item, &to_whom_name) }
}).await?; let to_whom = search_item_for_user(
ctx,
&ItemSearchParams {
include_loc_contents: true,
limit: 1,
..ItemSearchParams::base(&player_item, &to_whom_name)
},
)
.await?;
match to_whom.item_type.as_str() { match to_whom.item_type.as_str() {
"npc" => {} "npc" => {}
"player" => {}, "player" => {}
_ => user_error("Only characters (players / NPCs) accept whispers".to_string())? _ => user_error("Only characters (players / NPCs) accept whispers".to_string())?,
} }
ctx.trans.queue_for_session(ctx.session, Some(&format!( if is_likely_illegal(&say_what) {
ansi!("<blue>{} whispers to {}: \"{}\"<reset>\n"), user_error("Your message was rejected by the content filter".to_string())?;
player_item.display_for_session(&ctx.session_dat), }
to_whom.display_for_session(&ctx.session_dat),
say_what ctx.trans
))).await?; .queue_for_session(
ctx.session,
Some(&format!(
ansi!("<blue>{} whispers to {}: \"{}\"<reset>\n"),
player_item.display_for_sentence(1, false),
to_whom.display_for_sentence(1, false),
say_what
)),
)
.await?;
if player_item == to_whom { if player_item == to_whom {
return Ok(()); return Ok(());
@ -41,32 +62,38 @@ impl UserVerb for Verb {
match to_whom.item_type.as_str() { match to_whom.item_type.as_str() {
"npc" => { "npc" => {
let npc = npc_by_code().get(to_whom.item_code.as_str()) let npc = npc_by_code()
.get(to_whom.item_code.as_str())
.map(Ok) .map(Ok)
.unwrap_or_else(|| user_error("That NPC is no longer available".to_owned()))?; .unwrap_or_else(|| user_error("That NPC is no longer available".to_owned()))?;
if let Some(handler) = npc.message_handler { if let Some(handler) = npc.message_handler {
handler.handle(ctx, &player_item, &to_whom, &say_what).await?; handler
.handle(ctx, &player_item, &to_whom, &say_what)
.await?;
} }
} }
"player" => { "player" => {
match ctx.trans.find_session_for_player(&to_whom.item_code).await? { match ctx
.trans
.find_session_for_player(&to_whom.item_code)
.await?
{
None => user_error("That character is asleep.".to_string())?, None => user_error("That character is asleep.".to_string())?,
Some((other_session, other_session_dets)) => { Some((other_session, _other_session_dets)) => {
if other_session_dets.less_explicit_mode && is_likely_explicit(&say_what) { ctx.trans
user_error("That player is on a client that doesn't allow explicit \ .queue_for_session(
content, and your message looked explicit, so it wasn't sent." &other_session,
.to_owned())? Some(&format!(
} else { ansi!("<blue>{} whispers to {}: \"{}\"<reset>\n"),
ctx.trans.queue_for_session(&other_session, Some(&format!( player_item.display_for_sentence(1, false),
ansi!("<blue>{} whispers to {}: \"{}\"<reset>\n"), to_whom.display_for_sentence(1, false),
player_item.display_for_session(&ctx.session_dat), say_what
to_whom.display_for_session(&ctx.session_dat), )),
say_what )
))).await?; .await?;
}
} }
} }
}, }
_ => {} _ => {}
} }

View File

@ -0,0 +1,41 @@
use super::{UResult, UserVerb, UserVerbRef, VerbContext};
use ansi::{ansi, ignore_special_characters};
use async_trait::async_trait;
use chrono::Utc;
pub struct Verb;
#[async_trait]
impl UserVerb for Verb {
async fn handle(
self: &Self,
ctx: &mut VerbContext,
_verb: &str,
_remaining: &str,
) -> UResult<()> {
let mut msg = String::new();
msg.push_str(&format!(
ansi!("<bold><bgblue><white>| {:20} | {:20} | {:15} |<reset>\n"),
ansi!("Username"),
ansi!("Corp"),
ansi!("Idle")
));
for online in ctx.trans.get_online_info().await? {
if let Some(online_time) = online.time {
let diff = humantime::format_duration(std::time::Duration::from_secs(
(Utc::now() - online_time).num_seconds() as u64,
));
msg.push_str(&format!(
"| {:20} | {:20} | {:15} |\n",
&ignore_special_characters(&online.username),
&ignore_special_characters(&online.corp.unwrap_or("".to_string())),
&format!("{}", &diff)
));
}
}
msg.push_str("\n");
ctx.trans.queue_for_session(ctx.session, Some(&msg)).await?;
Ok(())
}
}
static VERB_INT: Verb = Verb;
pub static VERB: UserVerbRef = &VERB_INT as UserVerbRef;

Some files were not shown because too many files have changed in this diff Show More