From 7a99813dd0ef9b2c3cfaca461547f5e7ad28728f Mon Sep 17 00:00:00 2001 From: Casper3667 <8396443+Casper3667@users.noreply.github.com> Date: Fri, 19 Jun 2026 06:36:07 +0200 Subject: [PATCH] The Case Dossier program (#22703) This adds the case dossier program. A program intended for investigators to use for their cases, with the ability to record evidence, photos, documents, people and their notes on a case. The rest of security is able to view cases in the program, but not edit them. Additionally, the forensic skill was added to evidence bags and photos, allowing a person to add the time, place and the person using them, mainly intended to be used with the case dossier program. Fibers and prints can now be gathered from objects inside evidence bags, to avoid contamination. Also fixed some bugs with taking photos of certain objects, such as airlocks. https://github.com/user-attachments/assets/92a880e9-9032-46d4-88c0-43d45b64f979 --- aurorastation.dme | 6 + code/__HELPERS/icons.dm | 11 +- code/datums/case_dossier/case_evidence.dm | 95 + .../datums/case_dossier/case_investigation.dm | 576 +++++ code/datums/case_dossier/case_paper.dm | 183 ++ code/datums/case_dossier/case_person.dm | 25 + code/datums/case_dossier/case_report.dm | 53 + code/datums/records.dm | 1 + code/datums/skills/occupational/medical.dm | 15 +- .../detectivework/tools/evidencebag.dm | 109 +- .../detectivework/tools/sample_kits.dm | 8 + .../file_system/programs/app_presets_.dm | 1 + .../programs/security/casedossier.dm | 299 +++ code/modules/paperwork/photography.dm | 42 +- html/changelogs/CaseDossierFiles.yml | 10 + tgui/packages/tgui/index.tsx | 2 +- tgui/packages/tgui/interfaces/Casedossier.tsx | 1948 +++++++++++++++++ .../tgui/styles/interfaces/CaseDossier.scss | 237 ++ tgui/packages/tgui/styles/main.scss | 1 + tgui/packages/tgui/styles/themes/dossier.scss | 73 + 20 files changed, 3671 insertions(+), 24 deletions(-) create mode 100644 code/datums/case_dossier/case_evidence.dm create mode 100644 code/datums/case_dossier/case_investigation.dm create mode 100644 code/datums/case_dossier/case_paper.dm create mode 100644 code/datums/case_dossier/case_person.dm create mode 100644 code/datums/case_dossier/case_report.dm create mode 100644 code/modules/modular_computers/file_system/programs/security/casedossier.dm create mode 100644 html/changelogs/CaseDossierFiles.yml create mode 100644 tgui/packages/tgui/interfaces/Casedossier.tsx create mode 100644 tgui/packages/tgui/styles/interfaces/CaseDossier.scss create mode 100644 tgui/packages/tgui/styles/themes/dossier.scss 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