diff --git a/aurorastation.dme b/aurorastation.dme index ba084b4b7f8..0c917daa146 100644 --- a/aurorastation.dme +++ b/aurorastation.dme @@ -491,6 +491,11 @@ #include "code\datums\tgui_module.dm" #include "code\datums\verb_callbacks.dm" #include "code\datums\weakrefs.dm" +#include "code\datums\case_dossier\case_evidence.dm" +#include "code\datums\case_dossier\case_investigation.dm" +#include "code\datums\case_dossier\case_paper.dm" +#include "code\datums\case_dossier\case_person.dm" +#include "code\datums\case_dossier\case_report.dm" #include "code\datums\changelog\changelog.dm" #include "code\datums\components\_component.dm" #include "code\datums\components\_hivenetechoes.dm" @@ -3127,6 +3132,7 @@ #include "code\modules\modular_computers\file_system\programs\research\ntmonitor.dm" #include "code\modules\modular_computers\file_system\programs\research\robotics.dm" #include "code\modules\modular_computers\file_system\programs\security\camera.dm" +#include "code\modules\modular_computers\file_system\programs\security\casedossier.dm" #include "code\modules\modular_computers\file_system\programs\security\digitalwarrant.dm" #include "code\modules\modular_computers\file_system\programs\security\guntracker.dm" #include "code\modules\modular_computers\file_system\programs\security\implant_tracker.dm" diff --git a/code/__HELPERS/icons.dm b/code/__HELPERS/icons.dm index 7d416d13f84..ce783e87533 100644 --- a/code/__HELPERS/icons.dm +++ b/code/__HELPERS/icons.dm @@ -683,12 +683,7 @@ world addY1 = min(flatY1, layer_image.pixel_y + 1) addY2 = max(flatY2, layer_image.pixel_y + add.Height()) - if ( - addX1 != flatX1 \ - && addX2 != flatX2 \ - && addY1 != flatY1 \ - && addY2 != flatY2 \ - ) + if(addX1 != flatX1 || addX2 != flatX2 || addY1 != flatY1 || addY2 != flatY2) // Resize the flattened icon so the new icon fits flat.Crop( addX1 - flatX1 + 1, @@ -698,8 +693,8 @@ world ) flatX1 = addX1 - flatX2 = addY1 - flatY1 = addX2 + flatX2 = addX2 + flatY1 = addY1 flatY2 = addY2 // Blend the overlay into the flattened icon diff --git a/code/datums/case_dossier/case_evidence.dm b/code/datums/case_dossier/case_evidence.dm new file mode 100644 index 00000000000..032d711f497 --- /dev/null +++ b/code/datums/case_dossier/case_evidence.dm @@ -0,0 +1,95 @@ +/datum/evidence_item + /// The id of the evidence item. Assigned automatically. + var/id = "" + /// Evidence name + var/label = "" + /// The category type a piece of evidence is, such as contraband + var/evidence_type = "Item" + /// The location the evidence was found + var/location = "" + /// The locker the evidence is in + var/evidence_locker = "" + /// The person to collect the evidence + var/collected_by = "" + /// The time the evidence was collected + var/collected_at = "" + /// The notes attached to the evidence + var/notes = "" + /// The people related to the evidence + var/list/linked_people = list() + +/datum/evidence_item/proc/tgui_data() + var/list/data = list() + data["id"] = id + + data["label"] = label + data["type"] = evidence_type + data["location"] = location + data["evidence_locker"] = evidence_locker + data["linked_people"] = linked_people + data["collected_by"] = collected_by + data["collected_at"] = collected_at + data["notes"] = notes + + data["title"] = label + data["author"] = collected_by + data["created_at"] = collected_at + + return data + +/datum/evidence_item/New(var/id_input, var/label_input, var/evidence_type_input, var/collected_by_input, var/collected_at_input, var/collected_location_input) + ..() + id = "E-[id_input]" + label = label_input + evidence_type = evidence_type_input + + collected_by = collected_by_input + collected_at = collected_at_input + location = collected_location_input + +/datum/evidence_item/Destroy(force) + linked_people.Cut() + return ..() + +/datum/evidence_item/photo + /// The photo id, used as part of ensuring the viewing client gets the proper photo + var/photo_id = "" + /// The image of the photo + var/icon/img + /// What is scribbled on the photo + var/scribble = "" + /// The associated evidence of the photo + var/list/linked_evidence = list() + +/datum/evidence_item/photo/New(id_input, i_label_input, evidence_type_input = "Photo", collected_by_input, +collected_at_input, collected_location_input, var/photo_id_input, var/photo_img_input, var/photo_scribble_input) + ..() + id = "P-[id_input]" + photo_id = photo_id_input + img = photo_img_input + scribble = photo_scribble_input + +/datum/evidence_item/photo/Destroy(force) + linked_evidence.Cut() + return ..() + +/datum/evidence_item/photo/tgui_data() + var/list/data = list() + data["id"] = id + data["photo_id"] = photo_id + data["caption"] = label + data["type"] = evidence_type + data["location"] = location + data["taken_by"] = collected_by + data["taken_at"] = collected_at + data["collected_by"] = collected_by + data["collected_at"] = collected_at + data["notes"] = notes + data["scribble"] = scribble + data["linked_evidence"] = linked_evidence + + if(img) + data["image"] = "tmp_photo_[photo_id || id].png" + + return data + diff --git a/code/datums/case_dossier/case_investigation.dm b/code/datums/case_dossier/case_investigation.dm new file mode 100644 index 00000000000..d823d73919b --- /dev/null +++ b/code/datums/case_dossier/case_investigation.dm @@ -0,0 +1,576 @@ +#define STATUS_OPEN "Open" +#define STATUS_SUBMITTED "Submitted" +#define STATUS_ARCHIVED "Archived" +/datum/investigation_case + /// The id of the case. Assigned automatically. + var/case_id = "" + /// The case name + var/title = "new case" + /// Case status, determining its current status and ability to be editted + var/status = "Open" + /// The investigators working on the case + var/investigator = "" + /// Tags associated with the case + var/list/tags = list() + + /// Evidence associated with the case + var/list/evidence_refs = list() + /// Photos associated with the case + var/list/photo_refs = list() + /// Reports associated with the case + var/list/report_refs = list() + + /// Case summary + var/summary = "" + /// Who started the case + var/created_by = "" + /// When the case was opened + var/created_at = "" + /// When the case was last updated/editted + var/updated_at = "" + + /// Timeline description of the events of the case + var/timeline = "" + /// The findings of the case, such as motive + var/findings = "" + + /// The victims in the case + var/list/victims = list() + /// The suspects in the case + var/list/suspects = list() + /// The witnesses in the case + var/list/witnesses = list() + + /// Person IDs, set per case + var/person_uid = 1 + /// Evidence ID, set per case + var/evidence_uid = 1 + /// Photo ID, set per case + var/photo_uid = 1 + /// Report ID, set per case + var/report_uid = 1 + +/// The case ID, to be incremented per new case +GLOBAL_VAR_INIT(case_dossier_uid, 1) +/// All case dossiers +GLOBAL_LIST_EMPTY(case_dossier_cases) +/datum/investigation_case/New(var/datum/investigation_case/N, var/mob/creator) + ..() + case_id = "C-[worlddate2text()]-[GLOB.case_dossier_uid++]" + + if(creator) + created_by = creator.name + investigator = creator.name + created_at = worldtime2text() + updated_at = created_at + + if(!N) + return + + title = N.title + status = STATUS_OPEN + investigator = N.investigator + tags = N.tags.Copy() + + summary = N.summary + timeline = N.timeline + findings = N.findings + + person_uid = N.person_uid + evidence_uid = N.evidence_uid + photo_uid = N.photo_uid + report_uid = N.report_uid + + // Copy the people lists + victims = copy_person_list(N.victims) + suspects = copy_person_list(N.suspects) + witnesses = copy_person_list(N.witnesses) + + // Copy the lists of evidence, photos, and reports + evidence_refs = copy_evidence_list(N.evidence_refs) + photo_refs = copy_photo_list(N.photo_refs) + report_refs = copy_report_list(N.report_refs) + +/// Makes a copy of the people lists, for case duplication +/datum/investigation_case/proc/copy_person_list(var/list/source) + var/list/copied = list() + + for(var/datum/investigation_person/P in source) + var/datum/investigation_person/New_P = new + + New_P.id = P.id + New_P.name = P.name + New_P.role = P.role + New_P.notes = P.notes + + copied += New_P + + return copied + +/// Makes a copy of the evidence lists, for case duplication +/datum/investigation_case/proc/copy_evidence_list(var/list/source) + var/list/copied = list() + + for(var/datum/evidence_item/I in source) + var/datum/evidence_item/new_I = new + + new_I.id = I.id + new_I.label = I.label + new_I.evidence_type = I.evidence_type + new_I.location = I.location + new_I.evidence_locker = I.evidence_locker + new_I.collected_by = I.collected_by + new_I.collected_at = I.collected_at + new_I.notes = I.notes + new_I.linked_people = I.linked_people.Copy() + + copied += new_I + + return copied + +/// Makes a copy of the photo lists, for case duplication +/datum/investigation_case/proc/copy_photo_list(var/list/source) + var/list/copied = list() + + for(var/datum/evidence_item/photo/P in source) + var/datum/evidence_item/photo/new_P = new + + new_P.id = P.id + new_P.label = P.label + new_P.evidence_type = P.evidence_type + new_P.location = P.location + new_P.evidence_locker = P.evidence_locker + new_P.collected_by = P.collected_by + new_P.collected_at = P.collected_at + new_P.notes = P.notes + new_P.linked_people = P.linked_people.Copy() + + new_P.photo_id = P.photo_id + new_P.img = P.img + new_P.scribble = P.scribble + new_P.linked_evidence = P.linked_evidence.Copy() + + copied += new_P + + return copied + +/// Makes a copy of the report lists, for case duplication +/datum/investigation_case/proc/copy_report_list(var/list/source) + var/list/copied = list() + + for(var/datum/case_dossier_report/R in source) + var/datum/case_dossier_report/new_R = new + + new_R.id = R.id + new_R.title = R.title + new_R.evidence_type = R.evidence_type + new_R.author = R.author + new_R.created_at = R.created_at + new_R.collected_by = R.collected_by + new_R.collected_at = R.collected_at + new_R.scanned_by = R.scanned_by + new_R.scanned_at = R.scanned_at + new_R.content = R.content + new_R.notes = R.notes + + copied += new_R + + return copied + +/// Scans the evidence held in the active hand, into the case files +/datum/investigation_case/proc/scan_held_evidence(var/mob/user, var/obj/item/E) + if(!E) + return FALSE + // Temporary storage of case labels, to use in case evidence bags are used + /// The area the evidence was collected in + var/collected_area + /// The time the evidence was collected + var/collected_time + /// Who collected the evidence + var/collected_by + /// The label on the evidence bag the evidence is in, if any + var/bag_label + + // If it is an evidence bag, then we want what is inside of it + if(istype(E, /obj/item/evidencebag)) + var/obj/item/evidencebag/B = E + E = B.stored_item + collected_area = B.collected_location + collected_time = B.collected_time + collected_by = B.collected_by + bag_label = B.label_text + + if(!E) + to_chat(user, SPAN_NOTICE("The evidence bag is empty.")) + return FALSE + + if(istype(E, /obj/item/photo)) + return FALSE + + collected_by ||= user.name + + /// The evidence item + var/datum/evidence_item/I = new /datum/evidence_item(evidence_uid++, bag_label || E.name, "Item", collected_by, collected_time, collected_area) + + + evidence_refs += I + touch() + + return TRUE + +/datum/investigation_case/proc/scan_held_photo(var/mob/user, var/obj/item/E) + if(!E) + return FALSE + + // Temporary storage of case labels, to use in case evidence bags are used + /// The area the evidence was collected in + var/collected_area + /// The time the evidence was collected + var/collected_time + /// Who collected the evidence + var/collected_by + /// The label on the evidence bag the evidence is in, if any + var/bag_label + + // If it is an evidence bag, then we want what is inside of it, just like with regular evidence items + if(istype(E, /obj/item/evidencebag)) + var/obj/item/evidencebag/B = E + E = B.stored_item + collected_area = B.collected_location + collected_time = B.collected_time + collected_by = B.collected_by + bag_label = B.label_text + + if(!E) + to_chat(user, SPAN_NOTICE("The evidence bag is empty.")) + return FALSE + + if(!istype(E, /obj/item/photo)) + return FALSE + + /// The photo being copied + var/obj/item/photo/P = E + /// The evidence linked photo being stored + var/datum/evidence_item/photo/Photo = new(photo_uid++, bag_label || P.caption, "Photo", collected_by || P.taken_by, + collected_time || P.time_taken, collected_area || P.location_taken, P.id, P.img, P.scribble) + + photo_refs += Photo + touch() + + return TRUE + +/// Scan the new held paperwork to add to the case. Only takes singular pages +/datum/investigation_case/proc/scan_held_report(var/mob/user, var/obj/item/held_item) + /// The scanned piece of paper + var/obj/item/paper/P = held_item + + if(!istype(P, /obj/item/paper)) + to_chat(user, SPAN_WARNING("You need to hold a piece of paper to scan it.")) + return FALSE + + /// The new paper item + var/datum/case_dossier_report/R = new(report_uid++, P.name, "Scanned paper", "Unknown", worldtime2text(), user?.name, worldtime2text(), user?.name) + + var/rendered_content = "" + + // Part 1: Deal with languages + if(P.info) + rendered_content += P.parse_languages(user, P.info, FALSE, TRUE) + + // Part 2: Get the stamps + if(P.stamps) + rendered_content += P.stamps + + // Part 3: Done + R.content = rendered_content + + report_refs += R + touch() + + to_chat(user, SPAN_NOTICE("You scan \the [P] into [title].")) + return TRUE + +/// Sets the fields to the specified values +/datum/investigation_case/proc/set_field(var/field, var/value) + switch(field) + if("title") + title = value + if("status") + status = value + if("investigator") + investigator = value + if("summary") + summary = value + if("timeline") + timeline = value + if("findings") + findings = value + + touch() + +/// Figures out and sets the tags +/datum/investigation_case/proc/set_tags_from_text(var/value) + tags = list() + + if(!value) + touch() + return + + var/list/raw_tags = splittext(value, ",") + for(var/tag in raw_tags) + var/clean_tag = trim(tag) + if(clean_tag) + tags += clean_tag + + touch() + +/// We updated it. Time to set the updated time. +/datum/investigation_case/proc/touch() + updated_at = worldtime2text() + +/// Fetch the relevant people +/datum/investigation_case/proc/get_person_list(var/list_name) + switch(list_name) + if("victims") + return victims + if("suspects") + return suspects + if("witnesses") + return witnesses + + return + +/// Add a new person +/datum/investigation_case/proc/add_person(var/list_name) + var/list/L = get_person_list(list_name) + if(!L) + return FALSE + var/role = "Unknown" + switch(list_name) + if("victims") + role = "Victim" + if("suspects") + role = "Suspect" + if("witnesses") + role = "Witness" + + var/datum/investigation_person/P = new(person_uid++, role_input = role) + + + L += P + touch() + return TRUE + +/// Edit the values of a person +/datum/investigation_case/proc/edit_person(var/list_name, var/id, var/field, var/value) + var/list/L = get_person_list(list_name) + if(!L) + return FALSE + + for(var/datum/investigation_person/P in L) + if(P.id == id) + switch(field) + if("name") + P.name = value + if("role") + P.role = value + if("notes") + P.notes = value + + touch() + return TRUE + + return FALSE + +/// Alright removing a person +/datum/investigation_case/proc/remove_person(var/list_name, var/id) + var/list/L = get_person_list(list_name) + if(!L) + return FALSE + + for(var/datum/investigation_person/P in L) + if(P.id == id) + L -= P + qdel(P) + touch() + return TRUE + + return FALSE + +/// Send the person data to TGUI +/datum/investigation_case/proc/person_list_tgui_data(var/list/L) + var/list/out = list() + + for(var/datum/investigation_person/P in L) + out += list(P.tgui_data()) + + return out + +/// Send the evidence data to TGUI +/datum/investigation_case/proc/evidence_list_tgui_data(var/list/L) + var/list/out = list() + + for(var/datum/evidence_item/I in L) + out += list(I.tgui_data()) + + return out + +/// Send the report data to TGUI +/datum/investigation_case/proc/report_list_tgui_data(var/list/reports) + var/list/data = list() + + if(!reports) + return data + + for(var/datum/case_dossier_report/R in reports) + data += list(R.tgui_data()) + + return data + +/// Manually add evidence, instead of scanning it +/datum/investigation_case/proc/add_manual_evidence(var/mob/user) + var/datum/evidence_item/I = new(evidence_uid++, "New Evidence", "Item", user?.name, worldtime2text()) + evidence_refs += I + touch() + return TRUE + +/// Find a specific evidence based on ID +/datum/investigation_case/proc/find_evidence_in_list(var/list/L, var/id) + for(var/datum/evidence_item/I in L) + if(I.id == id) + return I + return + +/// Edit an evidence item +/datum/investigation_case/proc/edit_evidence_item(var/list/L, var/id, var/field, var/value) + var/datum/evidence_item/I = find_evidence_in_list(L, id) + if(!I) + return FALSE + + switch(field) + if("label", "caption", "title") + I.label = value + + if("type") + I.evidence_type = value + + if("location") + I.location = value + + if("evidence_locker") + I.evidence_locker = value + + if("notes") + I.notes = value + + if("collected_by", "taken_by", "author") + I.collected_by = value + + if("collected_at", "taken_at", "created_at") + I.collected_at = value + + if("linked_people") + I.linked_people = text_to_clean_list(value) + + if("linked_evidence") + if(istype(I, /datum/evidence_item/photo)) + var/datum/evidence_item/photo/P = I + P.linked_evidence = text_to_clean_list(value) + + touch() + return TRUE + +/// Remove an evidence item +/datum/investigation_case/proc/remove_evidence_item(var/list/L, var/id) + var/datum/evidence_item/I = find_evidence_in_list(L, id) + if(!I) + return FALSE + + L -= I + qdel(I) + touch() + return TRUE + +/// Remove an report +/datum/investigation_case/proc/remove_report_item(var/list/L, var/id) + var/datum/case_dossier_report/R = find_report_in_list(L, id) + if(!R) + return FALSE + + L -= R + qdel(R) + touch() + return TRUE + +/// Find a specific report based on ID +/datum/investigation_case/proc/find_report_in_list(var/list/L, var/id) + for(var/datum/case_dossier_report/I in L) + if(I.id == id) + return I + return + +/// Edit an evidence item +/datum/investigation_case/proc/edit_evidence(var/id, var/field, var/value) + return edit_evidence_item(evidence_refs, id, field, value) + +/// Remove evidence +/datum/investigation_case/proc/remove_evidence(var/id) + return remove_evidence_item(evidence_refs, id) + +/// Edit a photo +/datum/investigation_case/proc/edit_photo(var/id, var/field, var/value) + return edit_evidence_item(photo_refs, id, field, value) + +/// Remove a photo +/datum/investigation_case/proc/remove_photo(var/id) + return remove_evidence_item(photo_refs, id) + +/// remove a report +/datum/investigation_case/proc/remove_report(var/id) + return remove_report_item(report_refs, id) + +/// Makes an identical case +/datum/investigation_case/proc/copy_case() + var/datum/investigation_case/N = new(src) + + return N + +/// creates a clean list from a set of text +/datum/investigation_case/proc/text_to_clean_list(var/value) + var/list/out = list() + + if(!value) + return out + + var/list/raw_values = splittext(value, ",") + for(var/raw_value in raw_values) + var/clean_value = trim(raw_value) + if(clean_value) + out += clean_value + + return out + +/// Send a photo to a viewing user so they can see it +/datum/investigation_case/proc/send_photo_resources(var/mob/user) + if(!user) + return + + for(var/datum/evidence_item/photo/P in photo_refs) + if(!P.img) + continue + + send_rsc(user, P.img, "tmp_photo_[P.photo_id || P.id].png") + +/datum/investigation_case/Destroy(force) + // Photo might contain refs to evidence, both of which might reference people + QDEL_LIST(photo_refs) + QDEL_LIST(evidence_refs) + QDEL_LIST(report_refs) + QDEL_LIST(victims) + QDEL_LIST(witnesses) + QDEL_LIST(suspects) + ..() + +#undef STATUS_OPEN +#undef STATUS_SUBMITTED +#undef STATUS_ARCHIVED diff --git a/code/datums/case_dossier/case_paper.dm b/code/datums/case_dossier/case_paper.dm new file mode 100644 index 00000000000..c806e45ce56 --- /dev/null +++ b/code/datums/case_dossier/case_paper.dm @@ -0,0 +1,183 @@ +/datum/investigation_case/proc/paper_escape(var/value) + if(!value) + return "N/A" + + var/text = trim("[value]") + if(!length(text)) + return "N/A" + + text = html_encode(text) + + text = replacetext(text, "\[", "[") + text = replacetext(text, "\]", "]") + + text = replacetext(text, ascii2text(13) + ascii2text(10), "\[br\]") + text = replacetext(text, ascii2text(13), "\[br\]") + text = replacetext(text, ascii2text(10), "\[br\]") + + return text + +/datum/investigation_case/proc/paper_bullet(var/value) + return "\[*\] [paper_escape(value)]" + +/datum/investigation_case/proc/printable_person(var/datum/investigation_person/person, var/fallback_role) + var/list/parts = list() + + parts += person.role || fallback_role + parts += person.name || "Unnamed" + + if(person.notes) + parts += person.notes + + return paper_bullet(jointext(parts, " - ")) + +/datum/investigation_case/proc/printable_people() + var/list/out = list() + + for(var/datum/investigation_person/person in victims) + out += printable_person(person, "Victim") + + for(var/datum/investigation_person/person in suspects) + out += printable_person(person, "Suspect") + + for(var/datum/investigation_person/person in witnesses) + out += printable_person(person, "Witness") + + if(!length(out)) + out += paper_bullet("None recorded") + + return jointext(out, "\n") + +/datum/investigation_case/proc/printable_attached_documents() + var/list/out = list() + + for(var/datum/case_dossier_report/report in report_refs) + out += paper_bullet("[report.id || "Unindexed"] - [report.title || "Untitled paperwork"]") + + for(var/datum/evidence_item/photo/photo in photo_refs) + out += paper_bullet("[photo.id || "Unindexed"] - [photo.label || "Untitled photograph"]") + + if(!length(out)) + out += paper_bullet("None recorded") + + return jointext(out, "\n") + +/datum/investigation_case/proc/printable_evidence_item(var/datum/evidence_item/item) + var/id = paper_escape(item.id || "Unindexed") + var/name = paper_escape(item.label || "Unnamed evidence") + var/type = paper_escape(item.evidence_type || "Unspecified") + var/location = paper_escape(item.location || "Unspecified") + + return "\[*\] \[b\][id]\[/b\] - [name] - \[b\]Type:\[/b\] [type] - \[b\]Location:\[/b\] [location]" + +/datum/investigation_case/proc/printable_evidence() + var/list/out = list() + + for(var/datum/evidence_item/item in evidence_refs) + out += printable_evidence_item(item) + + for(var/datum/evidence_item/photo/photo in photo_refs) + out += printable_evidence_item(photo) + + if(!length(out)) + out += paper_bullet("None recorded") + + return jointext(out, "\n") + +/datum/investigation_case/proc/print_case_paper(var/mob/user, var/paper_name, var/text) + if(!user) + return FALSE + + var/obj/item/paper/P = new(get_turf(user)) + P.set_content(paper_name, text, FALSE) + user.put_in_any_hand_if_possible(P) + + to_chat(user, SPAN_NOTICE("You print [paper_name].")) + return TRUE + +/datum/investigation_case/proc/print_summary(var/mob/user) + var/list/narrative = list() + narrative += paper_bullet(summary || "No narrative recorded.") + + if(timeline) + narrative += paper_bullet("Timeline:") + narrative += "\[list\]" + narrative += print_multiline_list(timeline) + narrative += "\[/list\]" + + var/list/conclusion = list() + conclusion += paper_bullet(findings || "No findings recorded.") + + var/list/notes = list() + notes += paper_bullet("Status: [status]") + notes += paper_bullet("Created by: [created_by || "Unknown"]") + notes += paper_bullet("Created at: [created_at || "Unknown"]") + notes += paper_bullet("Last updated: [updated_at || "Unknown"]") + + if(length(tags)) + notes += paper_bullet("Tags: [jointext(tags, ", ")]") + + var/report = {" +\[table\]\[cell\]\[hr\]\[small\]\[center\]\[logo_scc\]\[br\]\[b\]Stellar Corporate Conglomerate +SCCV Horizon\[/b\]\[hr\]\[b\]Form 0205 +\[large\]Case Report\[/large\]\[/b\]\[/center\]\[hr\]\[b\]Date:\[/b\] \[date\] +\[b\]Index:\[/b\] \[field\] + +\[b\]Case Number:\[/b\] [paper_escape(case_id)] +\[b\]Attached Documents:\[/b\]\[list\] +[printable_attached_documents()] +\[/list\]\[hr\]\[b\]Involved Personnel:\[/b\]\[list\] +[printable_people()] +\[/list\]\[b\]Narrative:\[/b\]\[list\] +[jointext(narrative, "\n")] +\[/list\]\[b\]Conclusion:\[/b\]\[list\] +[jointext(conclusion, "\n")] +\[/list\]\[b\]Additional Notes:\[/b\]\[list\] +[jointext(notes, "\n")] +\[/list\]\[hr\]\[b\]Time Printed:\[/b\] [paper_escape(worldtime2text())] +\[b\]Investigative Personnel:\[/b\] [paper_escape(investigator || user.real_name)] +\[b\]Relevant Signatures:\[/b\] +\[field\]\[hr\]\[/table\]"} + + return print_case_paper(user, "SCCF-0205 - Case Report ([case_id])", report) + +/datum/investigation_case/proc/print_evidence_log(var/mob/user) + var/manifest = {" +\[table\]\[cell\]\[hr\]\[small\]\[center\]\[logo_scc\]\[br\]\[b\]Stellar Corporate Conglomerate +SCCV Horizon\[/b\]\[hr\]\[b\]Form 0211 +\[large\]Evidence Manifest\[/large\]\[/b\]\[/center\]\[hr\]\[b\]Date:\[/b\] \[date\] +\[b\]Index:\[/b\] \[field\] + +\[b\]Case Number:\[/b\] [paper_escape(case_id)] +\[b\]Attached Documents:\[/b\]\[list\] +[printable_attached_documents()] +\[/list\]\[hr\]\[b\]Evidence:\[/b\]\[list\] +[printable_evidence()] +\[/list\]\[hr\]\[b\]Investigative Personnel:\[/b\] [paper_escape(investigator || user.real_name)] +\[b\]Relevant Signatures:\[/b\] \[field\]\[hr\]\[/table\]"} + + return print_case_paper(user, "SCCF-0211 - Evidence Manifest ([case_id])", manifest) + +/datum/investigation_case/proc/print_multiline_list(var/value, var/fallback = "None recorded") + if(!value) + return paper_bullet(fallback) + + var/text = trim("[value]") + if(!length(text)) + return paper_bullet(fallback) + + var/list/out = list() + var/list/lines = splittext(text, ascii2text(10)) + + for(var/line in lines) + line = trim(line) + + if(!length(line)) + continue + + out += paper_bullet(line) + + if(!length(out)) + return paper_bullet(fallback) + + return jointext(out, "\n") diff --git a/code/datums/case_dossier/case_person.dm b/code/datums/case_dossier/case_person.dm new file mode 100644 index 00000000000..e425525865e --- /dev/null +++ b/code/datums/case_dossier/case_person.dm @@ -0,0 +1,25 @@ +/datum/investigation_person + /// The person's id + var/id = "" + /// The person's name + var/name = "" + /// The person's role + var/role = "" + /// Notes about the person + var/notes = "" + +/datum/investigation_person/New(var/id_input, var/name_input, var/role_input, var/note_input) + ..() + id = "P-[id_input]" + name = name_input + role = role_input + notes = note_input + +/datum/investigation_person/proc/tgui_data() + var/list/data = list() + data["id"] = id + data["name"] = name + data["role"] = role + data["notes"] = notes + return data + diff --git a/code/datums/case_dossier/case_report.dm b/code/datums/case_dossier/case_report.dm new file mode 100644 index 00000000000..9bc1dd63335 --- /dev/null +++ b/code/datums/case_dossier/case_report.dm @@ -0,0 +1,53 @@ +/datum/case_dossier_report + /// The id associated with the report + var/id + /// The report's title + var/title = "Untitled paperwork" + /// The report type. + var/evidence_type = "Scanned paper" + /// The author, mainly for future proofing + var/author + /// was created, for future proofing + var/created_at + /// Who collected it, for future proofing + var/collected_by + /// When it was collected, for future proofing + var/collected_at + /// Who scanned it + var/scanned_by + /// When it was scanned + var/scanned_at + /// What is written + var/content + /// Notes, for future proofing + var/notes + +/datum/case_dossier_report/New(var/id_input, var/title_input, var/evidence_type_input = "Scanned paper", var/author_input = "Unknown", var/created_input = worldtime2text(), var/collected_by_input = "Unknown", var/collected_at_input = worldtime2text(), var/scanned_by_input = "Unknown", var/scanned_at_input) + ..() + id = "R-[id_input]" + title = sanitize(title_input, MAX_NAME_LEN) + evidence_type = evidence_type_input + author = author_input + created_at = created_input + collected_by = collected_by_input + collected_at = collected_at_input + scanned_by = scanned_by_input + + scanned_at = scanned_at_input ? scanned_at_input : collected_at + +/datum/case_dossier_report/proc/tgui_data() + var/list/data = list() + + data["id"] = "[id]" + data["title"] = title + data["type"] = evidence_type + data["author"] = author + data["created_at"] = created_at + data["collected_by"] = collected_by + data["collected_at"] = collected_at + data["scanned_by"] = scanned_by + data["scanned_at"] = scanned_at + data["content"] = content + data["notes"] = notes + + return data diff --git a/code/datums/records.dm b/code/datums/records.dm index b74e06ceba1..852d61a2dd8 100644 --- a/code/datums/records.dm +++ b/code/datums/records.dm @@ -274,3 +274,4 @@ GLOBAL_VAR_INIT(shuttle_uid, 0) /datum/record/shuttle_assignment/New(var/for_shuttle) . = ..() shuttle = for_shuttle + diff --git a/code/datums/skills/occupational/medical.dm b/code/datums/skills/occupational/medical.dm index 2b2010142bb..908748f6aa8 100644 --- a/code/datums/skills/occupational/medical.dm +++ b/code/datums/skills/occupational/medical.dm @@ -104,9 +104,22 @@ /singleton/skill/forensics name = "Forensics" - description = "Not currently implemented." + description = "Governs your knowledge of evidence gathering and analyzing." maximum_level = SKILL_LEVEL_PROFESSIONAL uneducated_skill_cap = SKILL_LEVEL_TRAINED category = /singleton/skill_category/occupational subcategory = SKILL_SUBCATEGORY_MEDICAL component_type = FORENSICS_SKILL_COMPONENT + skill_level_descriptions = alist( + SKILL_LEVEL_UNFAMILIAR = "You have zero training or knowledge of forensic principles.", + SKILL_LEVEL_FAMILIAR = "You have minimal training on the basics of forensics and evidence preservation.
" \ + + "You can do the following:
" \ + + " - Automatically record time, place and gathering person on evidence bags and photos.", + SKILL_LEVEL_TRAINED = "You have years of formal training and experience with forensic principles.
" \ + + "You can do the following:
" \ + + " - Automatically record time, place and gathering person on evidence bags and photos.", + SKILL_LEVEL_PROFESSIONAL = "You have extensive training and experience with forensics.
" \ + + "You can do the following:
" \ + + " - Automatically record time, place and gathering person on evidence bags and photos.", + ) + required = TRUE diff --git a/code/modules/detectivework/tools/evidencebag.dm b/code/modules/detectivework/tools/evidencebag.dm index 4fd9d5f6bdd..c9efa430363 100644 --- a/code/modules/detectivework/tools/evidencebag.dm +++ b/code/modules/detectivework/tools/evidencebag.dm @@ -7,8 +7,16 @@ icon_state = "evidenceobj" item_state = "" w_class = WEIGHT_CLASS_SMALL - var/obj/item/stored_item = null + /// The item stored in the evidence bag + var/obj/item/stored_item + /// The label on the evidence bag var/label_text = "" + /// Who collected the item + var/collected_by = "" + /// Where the item was collected + var/collected_location = "" + /// WHen the item was collected + var/collected_time = "" /obj/item/evidencebag/mechanics_hints(mob/user, distance, is_adjacent) . += ..() @@ -65,9 +73,9 @@ human_user.visible_message("[human_user] puts \the [I] into \the [src].", SPAN_NOTICE("You put \the [I] inside \the [src]."),\ "You hear a rustle as someone puts something into a plastic bag.") - store_item(I) + store_item(I, user) -/obj/item/evidencebag/proc/store_item(obj/item/I) +/obj/item/evidencebag/proc/store_item(obj/item/I, mob/user) icon_state = "evidence" var/mutable_appearance/MA = new(I) MA.pixel_x = 0 @@ -75,6 +83,17 @@ MA.layer = FLOAT_LAYER AddOverlays(list(MA, "evidence")) + var/forensic = GET_SKILL_LEVEL(user, FORENSICS_SKILL_COMPONENT) + forensic = forensic ? forensic : SKILL_LEVEL_TRAINED + + // Trained people would know to add this data + if(forensic >= SKILL_LEVEL_FAMILIAR) + collected_location = get_area_display_name(get_area(I)) + collected_by = user.name + collected_time = worldtime2text() + // And untrained people risk contaminating it + else if (prob(25)) + I.add_fingerprint(user) desc = "A plastic bag containing [I]." I.forceMove(src) stored_item = I @@ -99,21 +118,60 @@ icon_state = "evidenceobj" return +/obj/item/evidencebag/feedback_hints(mob/user, distance, is_adjacent) + . = ..() + if(label_text) + . += SPAN_NOTICE("It is labelled: \"[label_text]\".") + + if(collected_by) + . += SPAN_NOTICE("Collected by: [collected_by].") + + if(collected_location) + . += SPAN_NOTICE("Collected from: [collected_location].") + + if(collected_time) + . += SPAN_NOTICE("Collected at: [collected_time].") + /obj/item/evidencebag/examine(mob/user, distance, is_adjacent, infix, suffix, show_extended) . = ..() - if (stored_item) + if(stored_item) examinate(user, stored_item, show_extended) /obj/item/evidencebag/attackby(obj/item/attacking_item, mob/user) if(attacking_item.tool_behaviour == TOOL_PEN || istype(attacking_item, /obj/item/flashlight/pen)) - var/tmp_label = sanitizeSafe( tgui_input_text(user, "Enter a label for [name]", "Label", label_text, MAX_NAME_LEN), MAX_NAME_LEN ) - if(length(tmp_label) > MAX_NAME_LEN) - to_chat(user, SPAN_NOTICE("The label can be at most [MAX_NAME_LEN] characters long.")) + var/static/list/evidence_label_fields = list( + "Label text", + "Collected by", + "Collected location", + "Collected time" + ) + + var/field = tgui_input_list(user, "Which evidence label field do you want to edit?", "Evidence Label", evidence_label_fields) + if(!field) + return + + var/current_value = get_evidence_label_value(field) + + var/new_value = tgui_input_text( + user, + "Enter [field] for [name]", + "Evidence Label", + current_value, + MAX_NAME_LEN + ) + + if(isnull(new_value)) + return + + set_evidence_label_value(field, new_value) + + if(new_value == "") + to_chat(user, SPAN_NOTICE("You clear [field].")) else - to_chat(user, SPAN_NOTICE("You set the label to \"[tmp_label]\".")) - label_text = tmp_label - update_name_label() + to_chat(user, SPAN_NOTICE("You set [field] to \"[new_value]\".")) + return + . = ..() /obj/item/evidencebag/proc/update_name_label(var/base_name = initial(name)) @@ -122,3 +180,34 @@ name = base_name else name = "[base_name] ([label_text])" + +/obj/item/evidencebag/proc/get_evidence_label_value(var/field) + switch(field) + if("Label text") + return label_text + if("Collected by") + return collected_by + if("Collected location") + return collected_location + if("Collected time") + return collected_time + + return "" + +/obj/item/evidencebag/proc/set_evidence_label_value(var/field, var/value) + switch(field) + if("Label text") + label_text = value + update_name_label() + return TRUE + if("Collected by") + collected_by = value + return TRUE + if("Collected location") + collected_location = value + return TRUE + if("Collected time") + collected_time = value + return TRUE + + return FALSE diff --git a/code/modules/detectivework/tools/sample_kits.dm b/code/modules/detectivework/tools/sample_kits.dm index 560f89c0445..ec4626eb99c 100644 --- a/code/modules/detectivework/tools/sample_kits.dm +++ b/code/modules/detectivework/tools/sample_kits.dm @@ -175,6 +175,7 @@ /obj/item/forensics/sample_kit/mechanics_hints(mob/user, distance, is_adjacent) . += ..() . += "Click drag it onto an object to collect fiber evidence. Alternatively, click on an object with non-Help intent." + . += "Click an evidence bag to collect evidence from the object within it. Alternatively, using harm intent checks the evidence bag." /obj/item/forensics/sample_kit/proc/can_take_sample(var/mob/user, var/atom/supplied) return (supplied.suit_fibers && supplied.suit_fibers.len) @@ -187,6 +188,13 @@ if(!proximity) return add_fingerprint(user) + + // We can sample items from within an evidence bag + if(user.a_intent != I_HURT && istype(A, /obj/item/evidencebag)) + var/obj/item/evidencebag/bag = A + if(bag.stored_item) + A = bag.stored_item + if(can_take_sample(user, A)) take_sample(user,A) return 1 diff --git a/code/modules/modular_computers/file_system/programs/app_presets_.dm b/code/modules/modular_computers/file_system/programs/app_presets_.dm index 63cac179b97..bc158b9cb32 100644 --- a/code/modules/modular_computers/file_system/programs/app_presets_.dm +++ b/code/modules/modular_computers/file_system/programs/app_presets_.dm @@ -78,5 +78,6 @@ ABSTRACT_TYPE(/datum/modular_computer_app_presets) /datum/computer_file/program/camera_monitor,\ /datum/computer_file/program/digitalwarrant,\ /datum/computer_file/program/records/security,\ + /datum/computer_file/program/case_dossier,\ /datum/computer_file/program/guntracker,\ ) diff --git a/code/modules/modular_computers/file_system/programs/security/casedossier.dm b/code/modules/modular_computers/file_system/programs/security/casedossier.dm new file mode 100644 index 00000000000..801fc5f106a --- /dev/null +++ b/code/modules/modular_computers/file_system/programs/security/casedossier.dm @@ -0,0 +1,299 @@ +#define STATUS_OPEN "Open" +#define STATUS_SUBMITTED "Submitted" +#define STATUS_ARCHIVED "Archived" +/datum/computer_file/program/case_dossier + filename = "case_dossier" + filedesc = "Case Dossier" + extended_desc = "Official NTsec program for the handling of investigation cases." + program_icon_state = "security_record" + program_key_icon_state = "yellow_key" + color = LIGHT_COLOR_RED + size = 6 + requires_ntnet = TRUE + available_on_ntnet = TRUE + // Security officers and wardens are allowed to download the program, but not edit it + required_access_download = ACCESS_SECURITY + required_access_run = ACCESS_SECURITY + usage_flags = PROGRAM_ALL_REGULAR | PROGRAM_STATIONBOUND + tgui_id = "Casedossier" + + var/list/stored_cases + var/datum/investigation_case/open_case + +/datum/computer_file/program/case_dossier/New(obj/item/modular_computer/comp) + . = ..() + stored_cases = GLOB.case_dossier_cases + +/datum/computer_file/program/case_dossier/Destroy() + stored_cases.Cut() + open_case = null + return ..() + +/// Who is permitted to edit the investigation files. Defaults to forensic staff +/datum/computer_file/program/case_dossier/proc/can_edit(mob/user) + if(!user) + return FALSE + + var/obj/item/card/id/I = user.GetIdCard() + if(!I) + return FALSE + + return (ACCESS_FORENSICS_LOCKERS in I.access) + +/datum/computer_file/program/case_dossier/ui_data(mob/user) + var/list/data = list() + + data["available_statuses"] = list(STATUS_OPEN, STATUS_SUBMITTED, STATUS_ARCHIVED) + data["can_edit"] = can_edit(user) + data["open_case"] = null + data["cases"] = list() + + if(open_case) + open_case.send_photo_resources(user) + data["open_case"] = open_case.tgui_data() + + if(stored_cases) + for(var/datum/investigation_case/C in stored_cases) + data["cases"] += list(C.tgui_data(TRUE)) + + return data + +/datum/investigation_case/proc/tgui_data(var/include_contents = TRUE) + var/list/data = list() + + data["id"] = "[case_id]" + data["title"] = title + data["status"] = status + data["investigator"] = investigator + data["created_at"] = created_at + data["updated_at"] = updated_at + data["tags"] = tags + + data["victim_count"] = length(victims) + data["suspect_count"] = length(suspects) + data["witness_count"] = length(witnesses) + data["evidence_count"] = length(evidence_refs) + data["photo_count"] = length(photo_refs) + data["report_count"] = length(report_refs) + + if(include_contents) + data["summary"] = summary + data["timeline"] = timeline + data["findings"] = findings + + data["victims"] = person_list_tgui_data(victims) + data["suspects"] = person_list_tgui_data(suspects) + data["witnesses"] = person_list_tgui_data(witnesses) + + data["evidence"] = evidence_list_tgui_data(evidence_refs) + data["photos"] = evidence_list_tgui_data(photo_refs) + data["reports"] = report_list_tgui_data(report_refs) + + return data + +/datum/computer_file/program/case_dossier/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + + switch(action) + if("new_case") + create_new_case(usr) + return TRUE + + if("open_case") + open_case_by_id(params["id"]) + return TRUE + + if("save_case") + save_open_case(usr) + return TRUE + + if("edit_case_field") + if(open_case) + open_case.set_field(params["field"], params["value"]) + else + to_chat(usr, SPAN_NOTICE("No open case selected.")) + return TRUE + + if("edit_case_tags") + if(open_case) + open_case.set_tags_from_text(params["value"]) + else + to_chat(usr, SPAN_NOTICE("No open case selected.")) + return TRUE + + if("add_person") + if(open_case) + open_case.add_person(params["list"]) + else + to_chat(usr, SPAN_NOTICE("No open case selected.")) + return TRUE + + if("edit_person") + if(open_case) + open_case.edit_person(params["list"], params["id"], params["field"], params["value"]) + else + to_chat(usr, SPAN_NOTICE("No open case selected.")) + return TRUE + + if("remove_person") + if(open_case) + open_case.remove_person(params["list"], params["id"]) + else + to_chat(usr, SPAN_NOTICE("No open case selected.")) + return TRUE + + if("scan_evidence") + if(open_case) + open_case.scan_held_evidence(usr, usr.get_type_in_hands(/obj/item)) + else + to_chat(usr, SPAN_NOTICE("No open case selected.")) + return TRUE + + if("scan_photo") + if(open_case) + open_case.scan_held_photo(usr, usr.get_type_in_hands(/obj/item)) + else + to_chat(usr, SPAN_NOTICE("No open case selected.")) + return TRUE + + if("scan_report") + if(open_case) + open_case.scan_held_report(usr, usr.get_type_in_hands(/obj/item)) + else + to_chat(usr, SPAN_NOTICE("No open case selected.")) + return TRUE + + if("print_summary") + if(open_case) + open_case.print_summary(usr) + else + to_chat(usr, SPAN_NOTICE("No open case selected.")) + return TRUE + + if("print_evidence_log") + if(open_case) + open_case.print_evidence_log(usr) + else + to_chat(usr, SPAN_NOTICE("No open case selected.")) + return TRUE + + if("set_case_status") + var/datum/investigation_case/C = find_case_by_id(params["id"]) + if(C) + C.set_field("status", params["status"]) + return TRUE + + if("duplicate_case") + duplicate_case_by_id(params["id"], usr) + return TRUE + + if("delete_case") + delete_case_by_id(params["id"]) + return TRUE + + if("add_manual_evidence") + if(open_case) + open_case.add_manual_evidence(usr) + return TRUE + + if("edit_evidence") + if(open_case) + open_case.edit_evidence(params["id"], params["field"], params["value"]) + return TRUE + + if("remove_evidence") + if(open_case) + open_case.remove_evidence(params["id"]) + return TRUE + + if("edit_photo") + if(open_case) + open_case.edit_photo(params["id"], params["field"], params["value"]) + return TRUE + + if("remove_photo") + if(open_case) + open_case.remove_photo(params["id"]) + return TRUE + + if("remove_report") + if(open_case) + open_case.remove_report(params["id"]) + return TRUE + +/// Starts a new case +/datum/computer_file/program/case_dossier/proc/create_new_case(var/mob/user) + var/datum/investigation_case/C = new /datum/investigation_case(creator = user) + + stored_cases += C + open_case = C + +/// Finds a new case by its id +/datum/computer_file/program/case_dossier/proc/find_case_by_id(var/id) + if(!id) + return null + + for(var/datum/investigation_case/C in stored_cases) + if("[C.case_id]" == "[id]") + return C + + return null + +/// Opens a case by its id +/datum/computer_file/program/case_dossier/proc/open_case_by_id(var/id) + var/datum/investigation_case/C = find_case_by_id(id) + if(!C) + return FALSE + + open_case = C + return TRUE + +/// Saves the open case +/// Right now it doesn't do much, as the program auto-saves. +/datum/computer_file/program/case_dossier/proc/save_open_case(var/mob/user) + if(!open_case) + return FALSE + + open_case.touch() + + if(user) + to_chat(user, SPAN_NOTICE("You save [open_case.title].")) + + return TRUE + +/datum/computer_file/program/case_dossier/proc/delete_case_by_id(var/id) + var/datum/investigation_case/C = find_case_by_id(id) + if(!C) + return FALSE + + stored_cases -= C + + if(open_case == C) + open_case = null + + qdel(C) + return TRUE + +/// Duplicates the case based on the id +/datum/computer_file/program/case_dossier/proc/duplicate_case_by_id(var/id, var/mob/user) + var/datum/investigation_case/C = find_case_by_id(id) + if(!C) + return FALSE + + var/datum/investigation_case/N = C.copy_case() + + N.title = "Copy of [C.title]" + N.created_by = user?.name + N.created_at = worldtime2text() + N.updated_at = N.created_at + + stored_cases += N + open_case = N + + return TRUE + +#undef STATUS_OPEN +#undef STATUS_SUBMITTED +#undef STATUS_ARCHIVED diff --git a/code/modules/paperwork/photography.dm b/code/modules/paperwork/photography.dm index 921268a4d5e..7e3694eb47f 100644 --- a/code/modules/paperwork/photography.dm +++ b/code/modules/paperwork/photography.dm @@ -26,7 +26,7 @@ /******** * photo * ********/ -GLOBAL_VAR_INIT(photo_count, 0) +GLOBAL_VAR_INIT(photo_count, 1) /obj/item/photo name = "photo" @@ -35,12 +35,26 @@ GLOBAL_VAR_INIT(photo_count, 0) icon_state = "photo" item_state = "paper" w_class = WEIGHT_CLASS_TINY - var/picture_desc // Who and/or what's in the picture. + /// Who and/or what's in the picture. + var/picture_desc + /// The photo's id var/id - var/icon/img //Big photo image - var/scribble //Scribble on the back. + /// Big photo image + var/icon/img + /// Scribble on the back. + var/scribble + /// The small size of the photo used for the icon var/icon/tiny + /// The size of the photo var/photo_size = 3 + /// The time the photo was taken + var/time_taken + /// The place the photo was taken + var/location_taken + /// Who took the photo + var/taken_by + /// The photo's name/caption + var/caption drop_sound = 'sound/items/drop/paper.ogg' pickup_sound = 'sound/items/pickup/paper.ogg' @@ -95,8 +109,10 @@ GLOBAL_VAR_INIT(photo_count, 0) if(surface_atom == usr || surface_atom.Adjacent(usr)) if(n_name) name = "[initial(name)] ([n_name])" + caption = n_name else name = initial(name) + caption = null add_fingerprint(usr) @@ -238,6 +254,12 @@ GLOBAL_VAR_INIT(photo_count, 0) desc = "A one use - polaroid camera." pictures_left = 30 +// Detective cameras have build in data recording, so no need for a skill check +/obj/item/camera/detective/record_data(mob/living/user, obj/item/photo/pic) + pic.time_taken = worldtime2text() + pic.location_taken = get_area_display_name(get_area(user)) + pic.taken_by = user.name + //Proc for capturing check /mob/living/proc/can_capture_turf(turf/T) return TRUE // DVIEW will do sanity checks, we've got no special checks. @@ -288,9 +310,21 @@ GLOBAL_VAR_INIT(photo_count, 0) p.pixel_x = rand(-10, 10) p.pixel_y = rand(-10, 10) p.photo_size = size + record_data(user, p) return p +/// Data recorded on photos. +/obj/item/camera/proc/record_data(mob/living/user, obj/item/photo/pic) + var/forensic = GET_SKILL_LEVEL(user, FORENSICS_SKILL_COMPONENT) + forensic = forensic ? forensic : SKILL_LEVEL_TRAINED + + // Trained people would know to add this data + if(forensic >= SKILL_LEVEL_FAMILIAR) + pic.time_taken = worldtime2text() + pic.location_taken = get_area_display_name(get_area(user)) + pic.taken_by = user.name + /obj/item/camera/proc/printpicture(mob/user, obj/item/photo/p) p.forceMove(user.loc) if(!user.get_inactive_hand()) diff --git a/html/changelogs/CaseDossierFiles.yml b/html/changelogs/CaseDossierFiles.yml new file mode 100644 index 00000000000..f84b5f6ad0c --- /dev/null +++ b/html/changelogs/CaseDossierFiles.yml @@ -0,0 +1,10 @@ +author: TheGreyWolf + +delete-after: True + +changes: + - rscadd: "Investigators now have the case dossier program. The rest of security can also read it in read-only mode." + - rscadd: "Evidence bags now labels the time, person and place when an item is bagged, if they have forensic skill. This can also be editted with a pen." + - rscadd: "Photos can now record similar data to the evidence bags if the user has the forensic skill, or they are using a detective camera. This cannot be editted." + - rscadd: "It is now possible to gather fibers and prints from objects inside evidence bags." + - bugfix: "Fixed some stuff with photos that should make them able to take photos of certain things such as airlocks again." diff --git a/tgui/packages/tgui/index.tsx b/tgui/packages/tgui/index.tsx index 22e6a1ff72c..2c07a35a9a0 100644 --- a/tgui/packages/tgui/index.tsx +++ b/tgui/packages/tgui/index.tsx @@ -34,8 +34,8 @@ import './styles/themes/hephaestus.scss'; import './styles/themes/sol.scss'; import './styles/themes/vaurca.scss'; import './styles/themes/amberpos.scss'; +import './styles/themes/dossier.scss'; import './styles/themes/orion.scss'; - import { setupGlobalEvents } from 'tgui-core/events'; import { setupHotKeys } from 'tgui-core/hotkeys'; import { captureExternalLinks } from 'tgui-core/links'; diff --git a/tgui/packages/tgui/interfaces/Casedossier.tsx b/tgui/packages/tgui/interfaces/Casedossier.tsx new file mode 100644 index 00000000000..fd96175c818 --- /dev/null +++ b/tgui/packages/tgui/interfaces/Casedossier.tsx @@ -0,0 +1,1948 @@ +import { + Box, + Button, + Dropdown, + Input, + LabeledList, + NoticeBox, + Section, + Stack, + Table, + Tabs, + TextArea, +} from 'tgui-core/components'; +import { useBackend, useLocalState } from '../backend'; +import { NtosWindow } from '../layouts'; +import { sanitizeText } from '../sanitize'; + +type CaseStatus = 'Open' | 'Submitted' | 'Archived'; + +type ActFinal = (action: string, payload?: Record) => void; +type SetStateFinal = (value: T) => void; + +type DossierPerson = { + id: string; + name: string; + role: string; + notes?: string; +}; + +type DossierEvidence = { + id: string; + label: string; + type: string; + location?: string; + evidence_locker?: string; + collected_by?: string; + collected_at?: string; + notes?: string; + linked_people?: string[]; +}; + +type DossierPhoto = { + id: string; + photo_id?: string; + caption: string; + label?: string; + type?: string; + location?: string; + taken_by?: string; + taken_at?: string; + collected_by?: string; + collected_at?: string; + notes?: string; + scribble?: string; + linked_evidence?: string[]; + image?: string; +}; + +type DossierReport = { + id: string; + title: string; + label?: string; + type: string; + author?: string; + created_at?: string; + collected_by?: string; + collected_at?: string; + notes?: string; + + scanned_by?: string; + scanned_at?: string; + content?: string; +}; + +type InvestigationCase = { + id: string; + title: string; + status: CaseStatus; + investigator?: string; + created_at?: string; + updated_at?: string; + tags?: string[]; + + summary?: string; + timeline?: string; + findings?: string; + + victims?: DossierPerson[]; + suspects?: DossierPerson[]; + witnesses?: DossierPerson[]; + + evidence?: DossierEvidence[]; + photos?: DossierPhoto[]; + reports?: DossierReport[]; +}; + +type Data = { + open_case?: InvestigationCase; + cases: InvestigationCase[]; + available_statuses: CaseStatus[]; + can_edit: boolean; +}; + +export const Casedossier = () => { + const { act, data } = useBackend(); + const { + open_case, + cases = [], + available_statuses = ['Open', 'Submitted', 'Archived'], + can_edit = false, + } = data; + + const [tab, setTab] = useLocalState('case_dossier_tab', 'open'); + const [caseFilter, setCaseFilter] = useLocalState( + 'case_dossier_filter', + 'All', + ); + const [search, setSearch] = useLocalState('case_dossier_search', ''); + + const [readOnly, setReadOnly] = useLocalState( + 'case_dossier_read_only', + false, + ); + + const permissionReadOnly = readOnly || !can_edit; + const caseStatusReadOnly = !!open_case && open_case.status !== 'Open'; + const effectiveOpenFileReadOnly = permissionReadOnly || caseStatusReadOnly; + + return ( + + + + setTab('open')} + > + Open File + + setTab('storage')} + > + Short-Term Storage + + + + + {!can_edit ? ( + + You do not have forensic access. This dossier is locked to + read-only mode. + + ) : open_case && open_case.status !== 'Open' ? ( + + This case is {open_case.status.toLowerCase()} and is locked to + read-only mode. + + ) : ( + + )} + + + {tab === 'open' && ( + + )} + + {tab === 'storage' && ( + + )} + + + ); +}; + +const OpenFileTab = (props: { + caseFile?: InvestigationCase; + statuses: CaseStatus[]; + readOnly: boolean; + act: ActFinal; +}) => { + const { caseFile, statuses, readOnly, act } = props; + + const [notesOpen, setNotesOpen] = useLocalState( + 'case_dossier_notes_open', + true, + ); + const [peopleOpen, setPeopleOpen] = useLocalState( + 'case_dossier_people_open', + true, + ); + const [evidenceOpen, setEvidenceOpen] = useLocalState( + 'case_dossier_evidence_open', + true, + ); + const [photosOpen, setPhotosOpen] = useLocalState( + 'case_dossier_photos_open', + true, + ); + const [reportsOpen, setReportsOpen] = useLocalState( + 'case_dossier_reports_open', + true, + ); + + if (!caseFile) { + return ( +
+ + No case file is currently open. Create or open a file from Short-Term + Storage. + + {!readOnly && ( + + )} +
+ ); + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) + } + > + + + + + + act('scan_photo')}> + Scan Held Photo + + ) + } + > + + + + + + act('scan_report')}> + Scan Held Paper + + ) + } + > + + + + + ); +}; + +const CaseHeader = (props: { + caseFile: InvestigationCase; + statuses: CaseStatus[]; + readOnly: boolean; + act: ActFinal; +}) => { + const { caseFile, statuses, readOnly, act } = props; + + return ( +
+ + + + {caseFile.id || 'Unnumbered'} - {caseFile.title || 'Untitled Case'} + {' - '} + + {readOnly && ( + + Read-only + + )} + + + + Investigator: {caseFile.investigator || 'Unassigned'} | Created:{' '} + {caseFile.created_at || 'Unknown'} | Updated:{' '} + {caseFile.updated_at || 'Unknown'} + + + + + + + + {!readOnly && ( + + + + + )} + + + + + + + + + + {!readOnly && ( + <> + + + + + + act('edit_case_field', { + field: 'title', + value, + }) + } + /> + + + + + act('edit_case_field', { + field: 'status', + value, + }) + } + /> + + + + + act('edit_case_field', { + field: 'investigator', + value, + }) + } + /> + + + + + act('edit_case_tags', { + value, + }) + } + /> + + + + {caseFile.created_at || 'Unknown'} + + + + )} +
+ ); +}; + +const CaseNarrativeSection = (props: { + caseFile: InvestigationCase; + readOnly: boolean; + act: ActFinal; +}) => { + const { caseFile, readOnly, act } = props; + + return ( + + + + Summary + + + act('edit_case_field', { + field: 'summary', + value, + }) + } + /> + + + + + + + Timeline + + + act('edit_case_field', { + field: 'timeline', + value, + }) + } + /> + + + + + Findings + + + act('edit_case_field', { + field: 'findings', + value, + }) + } + /> + + + + + ); +}; + +const CaseOverview = (props: { caseFile: InvestigationCase }) => { + const { caseFile } = props; + const evidenceCount = (caseFile.evidence || []).length; + const photoCount = (caseFile.photos || []).length; + const reportCount = (caseFile.reports || []).length; + + return ( +
+ + + + + + {getPeopleCount(caseFile)} total + + {evidenceCount} + {photoCount} + {reportCount} + +
+ ); +}; + +const CaseChecklist = (props: { caseFile: InvestigationCase }) => { + const { caseFile } = props; + const checks = [ + { + label: 'Summary written', + done: hasText(caseFile.summary), + }, + { + label: 'People listed', + done: getPeopleCount(caseFile) > 0, + }, + { + label: 'Evidence attached', + done: (caseFile.evidence || []).length > 0, + }, + { + label: 'Photo documentation attached', + done: (caseFile.photos || []).length > 0, + }, + { + label: 'Reports or paperwork attached', + done: (caseFile.reports || []).length > 0, + }, + { + label: 'Findings recorded', + done: hasText(caseFile.findings), + }, + ]; + + return ( +
+ {checks.map((check) => ( + + + {check.done ? '✓' : '✗'} + {' '} + {check.label} + + ))} +
+ ); +}; + +const PeopleDashboard = (props: { + caseFile: InvestigationCase; + readOnly: boolean; + act: ActFinal; +}) => { + const { caseFile, readOnly, act } = props; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +const PeopleSection = (props: { + title: string; + listKey: string; + people: DossierPerson[]; + readOnly: boolean; + act: ActFinal; +}) => { + const { title, listKey, people, readOnly, act } = props; + + return ( +
+ act('add_person', { + list: listKey, + }) + } + > + Add + + ) + } + > + {!people.length ? ( + No {title.toLowerCase()} listed. + ) : ( + + + ID + Name + Role + Notes + {!readOnly && } + + + {people.map((person) => ( + + {person.id} + + + act('edit_person', { + list: listKey, + id: person.id, + field: 'name', + value, + }) + } + /> + + + + + act('edit_person', { + list: listKey, + id: person.id, + field: 'role', + value, + }) + } + /> + + + + + act('edit_person', { + list: listKey, + id: person.id, + field: 'notes', + value, + }) + } + /> + + + {!readOnly && ( + +
+ )} +
+ ); +}; + +const EvidenceSection = (props: { + evidence: DossierEvidence[]; + people: DossierPerson[]; + readOnly: boolean; + act: ActFinal; +}) => { + const { evidence, people, readOnly, act } = props; + + return !evidence.length ? ( + + No evidence attached. Scan held evidence or add a manual entry. + + ) : ( + + {evidence.map((item) => ( + + + + ))} + + ); +}; + +const LinkedPeopleSelector = (props: { + item: DossierEvidence; + people: DossierPerson[]; + readOnly: boolean; + act: ActFinal; +}) => { + const { item, people, readOnly, act } = props; + const linkedPeople = item.linked_people || []; + + if (readOnly) { + return ( + { + const person = people.find( + (candidate) => candidate.id === personId, + ); + + return person + ? `${person.name || person.id} (${person.role || 'No role'})` + : personId; + }) + .join(', ')} + empty="No linked people." + /> + ); + } + + const options = people + .filter((person) => !linkedPeople.includes(person.id)) + .map((person) => ({ + displayText: `${person.id} - ${person.name || 'Unnamed'} (${person.role || 'No role'})`, + value: person.id, + })); + + const setLinkedPeople = (newLinkedPeople: string[]) => { + act('edit_evidence', { + id: item.id, + field: 'linked_people', + value: newLinkedPeople.join(', '), + }); + }; + + return ( + + + { + setLinkedPeople([...linkedPeople, value]); + }} + /> + + + {!!linkedPeople.length && ( + + + {linkedPeople.map((personId) => { + const person = people.find( + (candidate) => candidate.id === personId, + ); + + return ( + + + + ); + })} + + + )} + + ); +}; + +const EvidenceEntry = (props: { + item: DossierEvidence; + people: DossierPerson[]; + readOnly: boolean; + act: ActFinal; +}) => { + const { item, people, readOnly, act } = props; + + const [open, setOpen] = useLocalState( + `case_dossier_evidence_${item.id}_open`, + false, + ); + + const linkedPeople = item.linked_people || []; + const linkedPeopleText = linkedPeople + .map((personId) => { + const person = people.find((candidate) => candidate.id === personId); + + return person + ? `${person.name || person.id} (${person.role || 'No role'})` + : personId; + }) + .join(', '); + + return ( + + + + + {item.id} - {item.label || 'Unnamed evidence'} + + + + {item.type || 'No type'} | {item.location || 'No location'} + + + + Locker: {item.evidence_locker || 'No locker'} | Collected by:{' '} + {item.collected_by || 'Unknown'} | {item.collected_at || 'Unknown'} + + + {!!linkedPeople.length && ( + + + Linked: + + {linkedPeople.map((personId) => { + const person = people.find( + (candidate) => candidate.id === personId, + ); + + return ( + + {person + ? `${person.name || person.id} (${person.role || 'No role'})` + : personId} + + ); + })} + + )} + + + + + ) + } + > + + + setCaseFilter(value)} + /> + + + + setSearch(value)} + /> + + + + + + +
+ {!visibleCases.length ? ( + No matching case files. + ) : ( + + {visibleCases.map((caseFile) => ( + + + + + + {caseFile.id || 'Unnumbered'} -{' '} + {caseFile.title || 'Untitled Case'} + {' - '} + + + + Investigator: {caseFile.investigator || 'Unassigned'}{' '} + | Updated: {caseFile.updated_at || 'Unknown'} + + + + + + + + + + + + + + + + + {!readOnly && ( + + )} + + + {!readOnly && ( + + {caseFile.status !== 'Open' && ( + + )} + + + + + + )} + + + + + + ))} + + )} +
+
+
+ ); +}; + +const CollapsibleSection = (props: { + title: string; + count?: number; + total?: number; + open: boolean; + setOpen: SetStateFinal; + buttons?: any; + children: any; +}) => { + const { title, count, total, open, setOpen, buttons, children } = props; + const titleText = + typeof count === 'number' + ? total + ? `${title} — ${count}/${total}` + : `${title} — ${count}` + : title; + + return ( +
+ {buttons} +
+ ); +}; + +const ReadOnlyValue = (props: { value?: any; empty?: string }) => { + const { value, empty = '—' } = props; + const text = hasText(value) ? String(value) : empty; + + return ( + + {text} + + ); +}; + +const EditableInput = (props: { + readOnly: boolean; + value?: string; + empty?: string; + [key: string]: any; +}) => { + const { readOnly, value, empty, ...rest } = props; + + if (readOnly) { + return ; + } + + return ; +}; + +const EditableTextArea = (props: { + readOnly: boolean; + value?: string; + empty?: string; + [key: string]: any; +}) => { + const { readOnly, value, empty, ...rest } = props; + + if (readOnly) { + return ; + } + + return